claude-task-master 0.1.1__py3-none-any.whl → 0.1.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. claude_task_master/__init__.py +1 -1
  2. claude_task_master/api/__init__.py +98 -0
  3. claude_task_master/api/models.py +553 -0
  4. claude_task_master/api/routes.py +1135 -0
  5. claude_task_master/api/routes_config.py +160 -0
  6. claude_task_master/api/routes_control.py +278 -0
  7. claude_task_master/api/routes_webhooks.py +980 -0
  8. claude_task_master/api/server.py +551 -0
  9. claude_task_master/auth/__init__.py +89 -0
  10. claude_task_master/auth/middleware.py +448 -0
  11. claude_task_master/auth/password.py +332 -0
  12. claude_task_master/bin/claudetm +1 -1
  13. claude_task_master/cli.py +4 -0
  14. claude_task_master/cli_commands/__init__.py +2 -0
  15. claude_task_master/cli_commands/ci_helpers.py +114 -0
  16. claude_task_master/cli_commands/control.py +191 -0
  17. claude_task_master/cli_commands/fix_pr.py +260 -0
  18. claude_task_master/cli_commands/fix_session.py +174 -0
  19. claude_task_master/cli_commands/workflow.py +51 -3
  20. claude_task_master/core/__init__.py +13 -0
  21. claude_task_master/core/agent_message.py +27 -5
  22. claude_task_master/core/control.py +466 -0
  23. claude_task_master/core/orchestrator.py +316 -4
  24. claude_task_master/core/pr_context.py +7 -2
  25. claude_task_master/core/prompts_working.py +32 -12
  26. claude_task_master/core/state.py +84 -2
  27. claude_task_master/core/state_exceptions.py +9 -6
  28. claude_task_master/core/workflow_stages.py +160 -21
  29. claude_task_master/github/client_pr.py +43 -1
  30. claude_task_master/mcp/auth.py +153 -0
  31. claude_task_master/mcp/server.py +268 -10
  32. claude_task_master/mcp/tools.py +281 -0
  33. claude_task_master/server.py +489 -0
  34. claude_task_master/webhooks/__init__.py +73 -0
  35. claude_task_master/webhooks/client.py +703 -0
  36. claude_task_master/webhooks/config.py +565 -0
  37. claude_task_master/webhooks/events.py +639 -0
  38. {claude_task_master-0.1.1.dist-info → claude_task_master-0.1.3.dist-info}/METADATA +144 -6
  39. {claude_task_master-0.1.1.dist-info → claude_task_master-0.1.3.dist-info}/RECORD +42 -21
  40. {claude_task_master-0.1.1.dist-info → claude_task_master-0.1.3.dist-info}/entry_points.txt +2 -0
  41. {claude_task_master-0.1.1.dist-info → claude_task_master-0.1.3.dist-info}/WHEEL +0 -0
  42. {claude_task_master-0.1.1.dist-info → claude_task_master-0.1.3.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,980 @@
1
+ """REST API routes for webhook management.
2
+
3
+ This module provides CRUD endpoints for managing webhook configurations:
4
+
5
+ Endpoints:
6
+ - GET /webhooks: List all configured webhooks
7
+ - POST /webhooks: Create a new webhook configuration
8
+ - GET /webhooks/{webhook_id}: Get a specific webhook configuration
9
+ - PUT /webhooks/{webhook_id}: Update a webhook configuration
10
+ - DELETE /webhooks/{webhook_id}: Delete a webhook configuration
11
+ - POST /webhooks/test: Send a test webhook to verify configuration
12
+
13
+ Webhooks are stored in the state directory as webhooks.json and are used
14
+ by the orchestrator to send notifications about task lifecycle events.
15
+
16
+ Usage:
17
+ from claude_task_master.api.routes_webhooks import create_webhooks_router
18
+
19
+ router = create_webhooks_router()
20
+ app.include_router(router, prefix="/webhooks")
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import hashlib
26
+ import json
27
+ import logging
28
+ import uuid
29
+ from datetime import datetime
30
+ from pathlib import Path
31
+ from typing import TYPE_CHECKING, Any
32
+
33
+ from pydantic import BaseModel, Field, field_validator
34
+
35
+ from claude_task_master.webhooks import (
36
+ EventType,
37
+ WebhookClient,
38
+ WebhookDeliveryResult,
39
+ )
40
+
41
+ if TYPE_CHECKING:
42
+ from fastapi import APIRouter, Request
43
+ from fastapi.responses import JSONResponse
44
+
45
+ # Import FastAPI - using try/except for graceful degradation
46
+ try:
47
+ from fastapi import APIRouter, Request
48
+ from fastapi.responses import JSONResponse
49
+
50
+ FASTAPI_AVAILABLE = True
51
+ except ImportError:
52
+ FASTAPI_AVAILABLE = False
53
+
54
+ logger = logging.getLogger(__name__)
55
+
56
+ # Webhooks config file name
57
+ WEBHOOKS_FILE = "webhooks.json"
58
+
59
+
60
+ # =============================================================================
61
+ # Request/Response Models
62
+ # =============================================================================
63
+
64
+
65
+ class WebhookCreateRequest(BaseModel):
66
+ """Request model for creating a new webhook.
67
+
68
+ Attributes:
69
+ url: The webhook endpoint URL (must be http:// or https://).
70
+ secret: Optional shared secret for HMAC signature generation.
71
+ events: List of event types to subscribe to. Empty means all events.
72
+ enabled: Whether the webhook is active.
73
+ name: Optional friendly name for the webhook.
74
+ description: Optional description of the webhook's purpose.
75
+ timeout: Request timeout in seconds.
76
+ max_retries: Maximum retry attempts for failed deliveries.
77
+ verify_ssl: Whether to verify SSL certificates.
78
+ headers: Additional HTTP headers to include in requests.
79
+ """
80
+
81
+ url: str = Field(
82
+ ...,
83
+ min_length=1,
84
+ description="Webhook endpoint URL (must be http:// or https://)",
85
+ examples=["https://example.com/webhook"],
86
+ )
87
+ secret: str | None = Field(
88
+ default=None,
89
+ description="Shared secret for HMAC-SHA256 signature generation",
90
+ )
91
+ events: list[str] | None = Field(
92
+ default=None,
93
+ description="Event types to subscribe to (empty/null = all events)",
94
+ examples=[["task.completed", "pr.created"]],
95
+ )
96
+ enabled: bool = Field(
97
+ default=True,
98
+ description="Whether this webhook is active",
99
+ )
100
+ name: str | None = Field(
101
+ default=None,
102
+ max_length=100,
103
+ description="Optional friendly name for this webhook",
104
+ examples=["Production Slack Notifications"],
105
+ )
106
+ description: str | None = Field(
107
+ default=None,
108
+ max_length=500,
109
+ description="Optional description of this webhook's purpose",
110
+ )
111
+ timeout: float = Field(
112
+ default=30.0,
113
+ ge=1.0,
114
+ le=300.0,
115
+ description="Request timeout in seconds (1-300)",
116
+ )
117
+ max_retries: int = Field(
118
+ default=3,
119
+ ge=0,
120
+ le=10,
121
+ description="Maximum retry attempts for failed deliveries (0-10)",
122
+ )
123
+ verify_ssl: bool = Field(
124
+ default=True,
125
+ description="Whether to verify SSL certificates",
126
+ )
127
+ headers: dict[str, str] = Field(
128
+ default_factory=dict,
129
+ description="Additional HTTP headers to include in requests",
130
+ )
131
+
132
+ @field_validator("url")
133
+ @classmethod
134
+ def validate_url_scheme(cls, v: str) -> str:
135
+ """Ensure URL uses http:// or https:// scheme."""
136
+ if not v.startswith(("http://", "https://")):
137
+ raise ValueError("Webhook URL must start with http:// or https://")
138
+ return v
139
+
140
+ @field_validator("events", mode="before")
141
+ @classmethod
142
+ def validate_events(cls, v: Any) -> list[str] | None:
143
+ """Validate event types."""
144
+ if v is None:
145
+ return None
146
+ if isinstance(v, list):
147
+ if len(v) == 0:
148
+ return None
149
+ valid_events = {e.value for e in EventType}
150
+ for event in v:
151
+ if event not in valid_events:
152
+ raise ValueError(
153
+ f"Invalid event type: {event}. Valid types: {sorted(valid_events)}"
154
+ )
155
+ return v
156
+ raise ValueError("Events must be a list or null")
157
+
158
+
159
+ class WebhookUpdateRequest(BaseModel):
160
+ """Request model for updating an existing webhook.
161
+
162
+ All fields are optional - only provided fields are updated.
163
+
164
+ Attributes:
165
+ url: The webhook endpoint URL.
166
+ secret: Shared secret (set to empty string to remove).
167
+ events: Event types to subscribe to.
168
+ enabled: Whether the webhook is active.
169
+ name: Friendly name for the webhook.
170
+ description: Description of the webhook's purpose.
171
+ timeout: Request timeout in seconds.
172
+ max_retries: Maximum retry attempts.
173
+ verify_ssl: Whether to verify SSL certificates.
174
+ headers: Additional HTTP headers.
175
+ """
176
+
177
+ url: str | None = Field(default=None, min_length=1)
178
+ secret: str | None = Field(default=None)
179
+ events: list[str] | None = Field(default=None)
180
+ enabled: bool | None = Field(default=None)
181
+ name: str | None = Field(default=None, max_length=100)
182
+ description: str | None = Field(default=None, max_length=500)
183
+ timeout: float | None = Field(default=None, ge=1.0, le=300.0)
184
+ max_retries: int | None = Field(default=None, ge=0, le=10)
185
+ verify_ssl: bool | None = Field(default=None)
186
+ headers: dict[str, str] | None = Field(default=None)
187
+
188
+ @field_validator("url")
189
+ @classmethod
190
+ def validate_url_scheme(cls, v: str | None) -> str | None:
191
+ """Ensure URL uses http:// or https:// scheme."""
192
+ if v is not None and not v.startswith(("http://", "https://")):
193
+ raise ValueError("Webhook URL must start with http:// or https://")
194
+ return v
195
+
196
+ @field_validator("events", mode="before")
197
+ @classmethod
198
+ def validate_events(cls, v: Any) -> list[str] | None:
199
+ """Validate event types."""
200
+ if v is None:
201
+ return None
202
+ if isinstance(v, list):
203
+ if len(v) == 0:
204
+ return [] # Explicitly empty = clear filter
205
+ valid_events = {e.value for e in EventType}
206
+ for event in v:
207
+ if event not in valid_events:
208
+ raise ValueError(
209
+ f"Invalid event type: {event}. Valid types: {sorted(valid_events)}"
210
+ )
211
+ return v
212
+ raise ValueError("Events must be a list or null")
213
+
214
+ def has_updates(self) -> bool:
215
+ """Check if any updates were provided."""
216
+ # Check all fields except 'secret' which uses sentinel
217
+ for field_name in self.model_fields.keys():
218
+ value = getattr(self, field_name)
219
+ if value is not None:
220
+ return True
221
+ return False
222
+
223
+
224
+ class WebhookTestRequest(BaseModel):
225
+ """Request model for testing a webhook.
226
+
227
+ Can test either an existing webhook by ID or a new URL directly.
228
+
229
+ Attributes:
230
+ webhook_id: ID of an existing webhook to test.
231
+ url: URL to test directly (if not using webhook_id).
232
+ secret: Secret for direct URL testing.
233
+ """
234
+
235
+ webhook_id: str | None = Field(
236
+ default=None,
237
+ description="ID of an existing webhook to test",
238
+ )
239
+ url: str | None = Field(
240
+ default=None,
241
+ description="URL to test directly (alternative to webhook_id)",
242
+ )
243
+ secret: str | None = Field(
244
+ default=None,
245
+ description="Secret for direct URL testing",
246
+ )
247
+
248
+ @field_validator("url")
249
+ @classmethod
250
+ def validate_url_scheme(cls, v: str | None) -> str | None:
251
+ """Ensure URL uses http:// or https:// scheme."""
252
+ if v is not None and not v.startswith(("http://", "https://")):
253
+ raise ValueError("Webhook URL must start with http:// or https://")
254
+ return v
255
+
256
+
257
+ class WebhookResponse(BaseModel):
258
+ """Response model for a single webhook.
259
+
260
+ Attributes:
261
+ id: Unique webhook identifier.
262
+ url: Webhook endpoint URL.
263
+ has_secret: Whether a secret is configured (secret itself is not exposed).
264
+ events: List of subscribed event types (null = all events).
265
+ enabled: Whether the webhook is active.
266
+ name: Friendly name.
267
+ description: Description.
268
+ timeout: Request timeout in seconds.
269
+ max_retries: Maximum retry attempts.
270
+ verify_ssl: Whether SSL certificates are verified.
271
+ headers: Additional HTTP headers (values may be masked).
272
+ created_at: When the webhook was created.
273
+ updated_at: When the webhook was last updated.
274
+ """
275
+
276
+ id: str
277
+ url: str
278
+ has_secret: bool = False
279
+ events: list[str] | None = None
280
+ enabled: bool = True
281
+ name: str | None = None
282
+ description: str | None = None
283
+ timeout: float = 30.0
284
+ max_retries: int = 3
285
+ verify_ssl: bool = True
286
+ headers: dict[str, str] = Field(default_factory=dict)
287
+ created_at: datetime | str
288
+ updated_at: datetime | str
289
+
290
+
291
+ class WebhooksListResponse(BaseModel):
292
+ """Response model for listing webhooks.
293
+
294
+ Attributes:
295
+ success: Whether the request succeeded.
296
+ webhooks: List of webhook configurations.
297
+ total: Total number of webhooks.
298
+ """
299
+
300
+ success: bool = True
301
+ webhooks: list[WebhookResponse]
302
+ total: int
303
+
304
+
305
+ class WebhookCreateResponse(BaseModel):
306
+ """Response model for webhook creation.
307
+
308
+ Attributes:
309
+ success: Whether creation succeeded.
310
+ message: Human-readable result message.
311
+ webhook: The created webhook configuration.
312
+ """
313
+
314
+ success: bool = True
315
+ message: str
316
+ webhook: WebhookResponse
317
+
318
+
319
+ class WebhookDeleteResponse(BaseModel):
320
+ """Response model for webhook deletion.
321
+
322
+ Attributes:
323
+ success: Whether deletion succeeded.
324
+ message: Human-readable result message.
325
+ id: ID of the deleted webhook.
326
+ """
327
+
328
+ success: bool = True
329
+ message: str
330
+ id: str
331
+
332
+
333
+ class WebhookTestResponse(BaseModel):
334
+ """Response model for webhook test.
335
+
336
+ Attributes:
337
+ success: Whether the test webhook was delivered successfully.
338
+ message: Human-readable result message.
339
+ delivery_result: Details about the delivery attempt.
340
+ """
341
+
342
+ success: bool
343
+ message: str
344
+ status_code: int | None = None
345
+ delivery_time_ms: float | None = None
346
+ attempt_count: int = 1
347
+ error: str | None = None
348
+
349
+
350
+ class WebhookErrorResponse(BaseModel):
351
+ """Error response for webhook endpoints.
352
+
353
+ Attributes:
354
+ success: Always False.
355
+ error: Error type/code.
356
+ message: Human-readable error message.
357
+ detail: Additional error details.
358
+ """
359
+
360
+ success: bool = False
361
+ error: str
362
+ message: str
363
+ detail: str | None = None
364
+
365
+
366
+ # =============================================================================
367
+ # Storage Helpers
368
+ # =============================================================================
369
+
370
+
371
+ def _get_webhooks_file(request: Request) -> Path:
372
+ """Get the webhooks configuration file path.
373
+
374
+ Args:
375
+ request: FastAPI request object.
376
+
377
+ Returns:
378
+ Path to the webhooks.json file.
379
+ """
380
+ working_dir: Path = getattr(request.app.state, "working_dir", Path.cwd())
381
+ state_dir = working_dir / ".claude-task-master"
382
+ return state_dir / WEBHOOKS_FILE
383
+
384
+
385
+ def _load_webhooks(webhooks_file: Path) -> dict[str, dict[str, Any]]:
386
+ """Load webhooks from the configuration file.
387
+
388
+ Args:
389
+ webhooks_file: Path to the webhooks file.
390
+
391
+ Returns:
392
+ Dictionary mapping webhook IDs to webhook configurations.
393
+ """
394
+ if not webhooks_file.exists():
395
+ return {}
396
+
397
+ try:
398
+ with open(webhooks_file) as f:
399
+ data = json.load(f)
400
+ webhooks: dict[str, dict[str, Any]] = data.get("webhooks", {})
401
+ return webhooks
402
+ except (json.JSONDecodeError, OSError) as e:
403
+ logger.error(f"Failed to load webhooks file: {e}")
404
+ return {}
405
+
406
+
407
+ def _save_webhooks(webhooks_file: Path, webhooks: dict[str, dict[str, Any]]) -> None:
408
+ """Save webhooks to the configuration file.
409
+
410
+ Args:
411
+ webhooks_file: Path to the webhooks file.
412
+ webhooks: Dictionary mapping webhook IDs to configurations.
413
+ """
414
+ # Ensure state directory exists
415
+ webhooks_file.parent.mkdir(parents=True, exist_ok=True)
416
+
417
+ data = {"webhooks": webhooks, "updated_at": datetime.now().isoformat()}
418
+
419
+ with open(webhooks_file, "w") as f:
420
+ json.dump(data, f, indent=2, default=str)
421
+
422
+
423
+ def _generate_webhook_id(url: str) -> str:
424
+ """Generate a unique webhook ID based on URL hash and UUID.
425
+
426
+ Args:
427
+ url: The webhook URL.
428
+
429
+ Returns:
430
+ Unique webhook ID string.
431
+ """
432
+ # Use first 8 chars of URL hash + short UUID for uniqueness
433
+ url_hash = hashlib.sha256(url.encode()).hexdigest()[:8]
434
+ unique_id = str(uuid.uuid4())[:8]
435
+ return f"wh_{url_hash}_{unique_id}"
436
+
437
+
438
+ def _webhook_to_response(webhook_id: str, webhook: dict[str, Any]) -> WebhookResponse:
439
+ """Convert a stored webhook to response model.
440
+
441
+ Args:
442
+ webhook_id: The webhook ID.
443
+ webhook: The webhook configuration dictionary.
444
+
445
+ Returns:
446
+ WebhookResponse model instance.
447
+ """
448
+ return WebhookResponse(
449
+ id=webhook_id,
450
+ url=webhook["url"],
451
+ has_secret=bool(webhook.get("secret")),
452
+ events=webhook.get("events"),
453
+ enabled=webhook.get("enabled", True),
454
+ name=webhook.get("name"),
455
+ description=webhook.get("description"),
456
+ timeout=webhook.get("timeout", 30.0),
457
+ max_retries=webhook.get("max_retries", 3),
458
+ verify_ssl=webhook.get("verify_ssl", True),
459
+ headers=webhook.get("headers", {}),
460
+ created_at=webhook.get("created_at", datetime.now().isoformat()),
461
+ updated_at=webhook.get("updated_at", datetime.now().isoformat()),
462
+ )
463
+
464
+
465
+ # =============================================================================
466
+ # Webhooks Router
467
+ # =============================================================================
468
+
469
+
470
+ def create_webhooks_router() -> APIRouter:
471
+ """Create router for webhook management endpoints.
472
+
473
+ These endpoints allow CRUD operations on webhook configurations
474
+ and testing webhook delivery.
475
+
476
+ Returns:
477
+ APIRouter configured with webhook management endpoints.
478
+
479
+ Raises:
480
+ ImportError: If FastAPI is not installed.
481
+ """
482
+ if not FASTAPI_AVAILABLE:
483
+ raise ImportError(
484
+ "FastAPI not installed. Install with: pip install claude-task-master[api]"
485
+ )
486
+
487
+ router = APIRouter(tags=["Webhooks"])
488
+
489
+ # =========================================================================
490
+ # GET /webhooks - List all webhooks
491
+ # =========================================================================
492
+
493
+ @router.get(
494
+ "",
495
+ response_model=WebhooksListResponse,
496
+ responses={
497
+ 500: {"model": WebhookErrorResponse, "description": "Internal server error"},
498
+ },
499
+ summary="List Webhooks",
500
+ description="List all configured webhook endpoints.",
501
+ )
502
+ async def list_webhooks(request: Request) -> WebhooksListResponse | JSONResponse:
503
+ """List all configured webhooks.
504
+
505
+ Returns all webhook configurations without exposing secrets.
506
+
507
+ Returns:
508
+ WebhooksListResponse with list of webhooks.
509
+ """
510
+ try:
511
+ webhooks_file = _get_webhooks_file(request)
512
+ webhooks = _load_webhooks(webhooks_file)
513
+
514
+ webhook_responses = [
515
+ _webhook_to_response(wh_id, wh_data) for wh_id, wh_data in webhooks.items()
516
+ ]
517
+
518
+ return WebhooksListResponse(
519
+ success=True,
520
+ webhooks=webhook_responses,
521
+ total=len(webhook_responses),
522
+ )
523
+
524
+ except Exception as e:
525
+ logger.exception("Error listing webhooks")
526
+ return JSONResponse(
527
+ status_code=500,
528
+ content=WebhookErrorResponse(
529
+ error="internal_error",
530
+ message="Failed to list webhooks",
531
+ detail=str(e),
532
+ ).model_dump(),
533
+ )
534
+
535
+ # =========================================================================
536
+ # POST /webhooks - Create webhook
537
+ # =========================================================================
538
+
539
+ @router.post(
540
+ "",
541
+ response_model=WebhookCreateResponse,
542
+ status_code=201,
543
+ responses={
544
+ 400: {"model": WebhookErrorResponse, "description": "Invalid request"},
545
+ 409: {"model": WebhookErrorResponse, "description": "Webhook already exists"},
546
+ 500: {"model": WebhookErrorResponse, "description": "Internal server error"},
547
+ },
548
+ summary="Create Webhook",
549
+ description="Create a new webhook configuration.",
550
+ )
551
+ async def create_webhook(
552
+ request: Request, webhook_request: WebhookCreateRequest
553
+ ) -> WebhookCreateResponse | JSONResponse:
554
+ """Create a new webhook configuration.
555
+
556
+ Args:
557
+ webhook_request: Webhook configuration details.
558
+
559
+ Returns:
560
+ WebhookCreateResponse with created webhook.
561
+ """
562
+ try:
563
+ webhooks_file = _get_webhooks_file(request)
564
+ webhooks = _load_webhooks(webhooks_file)
565
+
566
+ # Check for duplicate URL
567
+ for existing_id, existing_webhook in webhooks.items():
568
+ if existing_webhook["url"] == webhook_request.url:
569
+ return JSONResponse(
570
+ status_code=409,
571
+ content=WebhookErrorResponse(
572
+ error="duplicate_webhook",
573
+ message=f"A webhook with URL '{webhook_request.url}' already exists",
574
+ detail=f"Existing webhook ID: {existing_id}",
575
+ ).model_dump(),
576
+ )
577
+
578
+ # Generate unique ID
579
+ webhook_id = _generate_webhook_id(webhook_request.url)
580
+
581
+ # Ensure ID is unique (very unlikely to collide, but check anyway)
582
+ while webhook_id in webhooks:
583
+ webhook_id = _generate_webhook_id(webhook_request.url + str(uuid.uuid4()))
584
+
585
+ # Create webhook configuration
586
+ now = datetime.now().isoformat()
587
+ webhook_data = {
588
+ "url": webhook_request.url,
589
+ "secret": webhook_request.secret,
590
+ "events": webhook_request.events,
591
+ "enabled": webhook_request.enabled,
592
+ "name": webhook_request.name,
593
+ "description": webhook_request.description,
594
+ "timeout": webhook_request.timeout,
595
+ "max_retries": webhook_request.max_retries,
596
+ "verify_ssl": webhook_request.verify_ssl,
597
+ "headers": webhook_request.headers,
598
+ "created_at": now,
599
+ "updated_at": now,
600
+ }
601
+
602
+ # Save webhook
603
+ webhooks[webhook_id] = webhook_data
604
+ _save_webhooks(webhooks_file, webhooks)
605
+
606
+ logger.info(f"Created webhook {webhook_id} for URL: {webhook_request.url}")
607
+
608
+ return WebhookCreateResponse(
609
+ success=True,
610
+ message="Webhook created successfully",
611
+ webhook=_webhook_to_response(webhook_id, webhook_data),
612
+ )
613
+
614
+ except Exception as e:
615
+ logger.exception("Error creating webhook")
616
+ return JSONResponse(
617
+ status_code=500,
618
+ content=WebhookErrorResponse(
619
+ error="internal_error",
620
+ message="Failed to create webhook",
621
+ detail=str(e),
622
+ ).model_dump(),
623
+ )
624
+
625
+ # =========================================================================
626
+ # GET /webhooks/{webhook_id} - Get specific webhook
627
+ # =========================================================================
628
+
629
+ @router.get(
630
+ "/{webhook_id}",
631
+ response_model=WebhookResponse,
632
+ responses={
633
+ 404: {"model": WebhookErrorResponse, "description": "Webhook not found"},
634
+ 500: {"model": WebhookErrorResponse, "description": "Internal server error"},
635
+ },
636
+ summary="Get Webhook",
637
+ description="Get a specific webhook configuration by ID.",
638
+ )
639
+ async def get_webhook(request: Request, webhook_id: str) -> WebhookResponse | JSONResponse:
640
+ """Get a specific webhook configuration.
641
+
642
+ Args:
643
+ webhook_id: The webhook ID.
644
+
645
+ Returns:
646
+ WebhookResponse with webhook configuration.
647
+ """
648
+ try:
649
+ webhooks_file = _get_webhooks_file(request)
650
+ webhooks = _load_webhooks(webhooks_file)
651
+
652
+ if webhook_id not in webhooks:
653
+ return JSONResponse(
654
+ status_code=404,
655
+ content=WebhookErrorResponse(
656
+ error="not_found",
657
+ message=f"Webhook '{webhook_id}' not found",
658
+ ).model_dump(),
659
+ )
660
+
661
+ return _webhook_to_response(webhook_id, webhooks[webhook_id])
662
+
663
+ except Exception as e:
664
+ logger.exception("Error getting webhook")
665
+ return JSONResponse(
666
+ status_code=500,
667
+ content=WebhookErrorResponse(
668
+ error="internal_error",
669
+ message="Failed to get webhook",
670
+ detail=str(e),
671
+ ).model_dump(),
672
+ )
673
+
674
+ # =========================================================================
675
+ # PUT /webhooks/{webhook_id} - Update webhook
676
+ # =========================================================================
677
+
678
+ @router.put(
679
+ "/{webhook_id}",
680
+ response_model=WebhookResponse,
681
+ responses={
682
+ 400: {"model": WebhookErrorResponse, "description": "Invalid request"},
683
+ 404: {"model": WebhookErrorResponse, "description": "Webhook not found"},
684
+ 409: {"model": WebhookErrorResponse, "description": "URL conflict"},
685
+ 500: {"model": WebhookErrorResponse, "description": "Internal server error"},
686
+ },
687
+ summary="Update Webhook",
688
+ description="Update an existing webhook configuration.",
689
+ )
690
+ async def update_webhook(
691
+ request: Request, webhook_id: str, update_request: WebhookUpdateRequest
692
+ ) -> WebhookResponse | JSONResponse:
693
+ """Update an existing webhook configuration.
694
+
695
+ Only provided fields are updated.
696
+
697
+ Args:
698
+ webhook_id: The webhook ID to update.
699
+ update_request: Fields to update.
700
+
701
+ Returns:
702
+ WebhookResponse with updated webhook configuration.
703
+ """
704
+ try:
705
+ webhooks_file = _get_webhooks_file(request)
706
+ webhooks = _load_webhooks(webhooks_file)
707
+
708
+ if webhook_id not in webhooks:
709
+ return JSONResponse(
710
+ status_code=404,
711
+ content=WebhookErrorResponse(
712
+ error="not_found",
713
+ message=f"Webhook '{webhook_id}' not found",
714
+ ).model_dump(),
715
+ )
716
+
717
+ # Check for URL conflict if URL is being updated
718
+ if update_request.url is not None:
719
+ for other_id, other_webhook in webhooks.items():
720
+ if other_id != webhook_id and other_webhook["url"] == update_request.url:
721
+ return JSONResponse(
722
+ status_code=409,
723
+ content=WebhookErrorResponse(
724
+ error="duplicate_webhook",
725
+ message=f"A webhook with URL '{update_request.url}' already exists",
726
+ detail=f"Existing webhook ID: {other_id}",
727
+ ).model_dump(),
728
+ )
729
+
730
+ # Update fields
731
+ webhook = webhooks[webhook_id]
732
+
733
+ if update_request.url is not None:
734
+ webhook["url"] = update_request.url
735
+ if update_request.secret is not None:
736
+ # Empty string clears the secret
737
+ webhook["secret"] = update_request.secret if update_request.secret else None
738
+ if update_request.events is not None:
739
+ # Empty list clears the filter (all events)
740
+ webhook["events"] = update_request.events if update_request.events else None
741
+ if update_request.enabled is not None:
742
+ webhook["enabled"] = update_request.enabled
743
+ if update_request.name is not None:
744
+ webhook["name"] = update_request.name if update_request.name else None
745
+ if update_request.description is not None:
746
+ webhook["description"] = (
747
+ update_request.description if update_request.description else None
748
+ )
749
+ if update_request.timeout is not None:
750
+ webhook["timeout"] = update_request.timeout
751
+ if update_request.max_retries is not None:
752
+ webhook["max_retries"] = update_request.max_retries
753
+ if update_request.verify_ssl is not None:
754
+ webhook["verify_ssl"] = update_request.verify_ssl
755
+ if update_request.headers is not None:
756
+ webhook["headers"] = update_request.headers
757
+
758
+ webhook["updated_at"] = datetime.now().isoformat()
759
+
760
+ # Save changes
761
+ _save_webhooks(webhooks_file, webhooks)
762
+
763
+ logger.info(f"Updated webhook {webhook_id}")
764
+
765
+ return _webhook_to_response(webhook_id, webhook)
766
+
767
+ except Exception as e:
768
+ logger.exception("Error updating webhook")
769
+ return JSONResponse(
770
+ status_code=500,
771
+ content=WebhookErrorResponse(
772
+ error="internal_error",
773
+ message="Failed to update webhook",
774
+ detail=str(e),
775
+ ).model_dump(),
776
+ )
777
+
778
+ # =========================================================================
779
+ # DELETE /webhooks/{webhook_id} - Delete webhook
780
+ # =========================================================================
781
+
782
+ @router.delete(
783
+ "/{webhook_id}",
784
+ response_model=WebhookDeleteResponse,
785
+ responses={
786
+ 404: {"model": WebhookErrorResponse, "description": "Webhook not found"},
787
+ 500: {"model": WebhookErrorResponse, "description": "Internal server error"},
788
+ },
789
+ summary="Delete Webhook",
790
+ description="Delete a webhook configuration.",
791
+ )
792
+ async def delete_webhook(
793
+ request: Request, webhook_id: str
794
+ ) -> WebhookDeleteResponse | JSONResponse:
795
+ """Delete a webhook configuration.
796
+
797
+ Args:
798
+ webhook_id: The webhook ID to delete.
799
+
800
+ Returns:
801
+ WebhookDeleteResponse with deletion result.
802
+ """
803
+ try:
804
+ webhooks_file = _get_webhooks_file(request)
805
+ webhooks = _load_webhooks(webhooks_file)
806
+
807
+ if webhook_id not in webhooks:
808
+ return JSONResponse(
809
+ status_code=404,
810
+ content=WebhookErrorResponse(
811
+ error="not_found",
812
+ message=f"Webhook '{webhook_id}' not found",
813
+ ).model_dump(),
814
+ )
815
+
816
+ # Delete webhook
817
+ del webhooks[webhook_id]
818
+ _save_webhooks(webhooks_file, webhooks)
819
+
820
+ logger.info(f"Deleted webhook {webhook_id}")
821
+
822
+ return WebhookDeleteResponse(
823
+ success=True,
824
+ message="Webhook deleted successfully",
825
+ id=webhook_id,
826
+ )
827
+
828
+ except Exception as e:
829
+ logger.exception("Error deleting webhook")
830
+ return JSONResponse(
831
+ status_code=500,
832
+ content=WebhookErrorResponse(
833
+ error="internal_error",
834
+ message="Failed to delete webhook",
835
+ detail=str(e),
836
+ ).model_dump(),
837
+ )
838
+
839
+ # =========================================================================
840
+ # POST /webhooks/test - Test webhook
841
+ # =========================================================================
842
+
843
+ @router.post(
844
+ "/test",
845
+ response_model=WebhookTestResponse,
846
+ responses={
847
+ 400: {"model": WebhookErrorResponse, "description": "Invalid request"},
848
+ 404: {"model": WebhookErrorResponse, "description": "Webhook not found"},
849
+ 500: {"model": WebhookErrorResponse, "description": "Internal server error"},
850
+ },
851
+ summary="Test Webhook",
852
+ description="Send a test webhook to verify configuration.",
853
+ )
854
+ async def test_webhook(
855
+ request: Request, test_request: WebhookTestRequest
856
+ ) -> WebhookTestResponse | JSONResponse:
857
+ """Send a test webhook to verify configuration.
858
+
859
+ Can test either an existing webhook by ID or a new URL directly.
860
+
861
+ Args:
862
+ test_request: Test request with webhook_id or url/secret.
863
+
864
+ Returns:
865
+ WebhookTestResponse with delivery result.
866
+ """
867
+ try:
868
+ # Determine webhook URL and secret
869
+ if test_request.webhook_id:
870
+ # Test existing webhook
871
+ webhooks_file = _get_webhooks_file(request)
872
+ webhooks = _load_webhooks(webhooks_file)
873
+
874
+ if test_request.webhook_id not in webhooks:
875
+ return JSONResponse(
876
+ status_code=404,
877
+ content=WebhookErrorResponse(
878
+ error="not_found",
879
+ message=f"Webhook '{test_request.webhook_id}' not found",
880
+ ).model_dump(),
881
+ )
882
+
883
+ webhook = webhooks[test_request.webhook_id]
884
+ url = webhook["url"]
885
+ secret = webhook.get("secret")
886
+ timeout = webhook.get("timeout", 30.0)
887
+ verify_ssl = webhook.get("verify_ssl", True)
888
+ headers = webhook.get("headers", {})
889
+
890
+ elif test_request.url:
891
+ # Test direct URL
892
+ url = test_request.url
893
+ secret = test_request.secret
894
+ timeout = 30.0
895
+ verify_ssl = True
896
+ headers = {}
897
+
898
+ else:
899
+ return JSONResponse(
900
+ status_code=400,
901
+ content=WebhookErrorResponse(
902
+ error="invalid_request",
903
+ message="Either webhook_id or url must be provided",
904
+ ).model_dump(),
905
+ )
906
+
907
+ # Create test payload
908
+ event_id = str(uuid.uuid4())
909
+ test_payload = {
910
+ "event_type": "webhook.test",
911
+ "event_id": event_id,
912
+ "timestamp": datetime.now().isoformat(),
913
+ "message": "This is a test webhook from Claude Task Master",
914
+ "test": True,
915
+ }
916
+
917
+ # Send test webhook
918
+ client = WebhookClient(
919
+ url=url,
920
+ secret=secret,
921
+ timeout=timeout,
922
+ max_retries=1, # Only try once for tests
923
+ verify_ssl=verify_ssl,
924
+ headers=headers,
925
+ )
926
+
927
+ result: WebhookDeliveryResult = await client.send(
928
+ data=test_payload,
929
+ event_type="webhook.test",
930
+ delivery_id=event_id,
931
+ )
932
+
933
+ if result.success:
934
+ return WebhookTestResponse(
935
+ success=True,
936
+ message="Test webhook delivered successfully",
937
+ status_code=result.status_code,
938
+ delivery_time_ms=result.delivery_time_ms,
939
+ attempt_count=result.attempt_count,
940
+ )
941
+ else:
942
+ return WebhookTestResponse(
943
+ success=False,
944
+ message="Test webhook delivery failed",
945
+ status_code=result.status_code,
946
+ delivery_time_ms=result.delivery_time_ms,
947
+ attempt_count=result.attempt_count,
948
+ error=result.error,
949
+ )
950
+
951
+ except Exception as e:
952
+ logger.exception("Error testing webhook")
953
+ return JSONResponse(
954
+ status_code=500,
955
+ content=WebhookErrorResponse(
956
+ error="internal_error",
957
+ message="Failed to test webhook",
958
+ detail=str(e),
959
+ ).model_dump(),
960
+ )
961
+
962
+ return router
963
+
964
+
965
+ # =============================================================================
966
+ # Exports
967
+ # =============================================================================
968
+
969
+ __all__ = [
970
+ "create_webhooks_router",
971
+ "WebhookCreateRequest",
972
+ "WebhookUpdateRequest",
973
+ "WebhookTestRequest",
974
+ "WebhookResponse",
975
+ "WebhooksListResponse",
976
+ "WebhookCreateResponse",
977
+ "WebhookDeleteResponse",
978
+ "WebhookTestResponse",
979
+ "WebhookErrorResponse",
980
+ ]