devs-webhook 1.2.0__tar.gz → 2.0.0__tar.gz

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 (53) hide show
  1. {devs_webhook-1.2.0 → devs_webhook-2.0.0}/PKG-INFO +1 -1
  2. {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook/config.py +15 -2
  3. {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook/core/claude_dispatcher.py +12 -5
  4. {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook/core/container_pool.py +92 -19
  5. {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook/github/models.py +1 -0
  6. {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook/github/parser.py +9 -2
  7. {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook/main_cli.py +14 -9
  8. {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook.egg-info/PKG-INFO +1 -1
  9. {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook.egg-info/SOURCES.txt +2 -0
  10. {devs_webhook-1.2.0 → devs_webhook-2.0.0}/pyproject.toml +1 -1
  11. devs_webhook-2.0.0/tests/test_cleanup_mode.py +165 -0
  12. devs_webhook-2.0.0/tests/test_stop_container_after_task.py +348 -0
  13. {devs_webhook-1.2.0 → devs_webhook-2.0.0}/tests/test_webhook_parser.py +120 -3
  14. {devs_webhook-1.2.0 → devs_webhook-2.0.0}/LICENSE +0 -0
  15. {devs_webhook-1.2.0 → devs_webhook-2.0.0}/README.md +0 -0
  16. {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook/__init__.py +0 -0
  17. {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook/app.py +0 -0
  18. {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook/cli/__init__.py +0 -0
  19. {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook/cli/worker.py +0 -0
  20. {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook/core/__init__.py +0 -0
  21. {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook/core/base_dispatcher.py +0 -0
  22. {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook/core/deduplication.py +0 -0
  23. {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook/core/repository_manager.py +0 -0
  24. {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook/core/task_processor.py +0 -0
  25. {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook/core/test_dispatcher.py +0 -0
  26. {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook/core/webhook_config.py +0 -0
  27. {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook/core/webhook_handler.py +0 -0
  28. {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook/github/__init__.py +0 -0
  29. {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook/github/app_auth.py +0 -0
  30. {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook/github/client.py +0 -0
  31. {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook/sources/__init__.py +0 -0
  32. {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook/sources/base.py +0 -0
  33. {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook/sources/sqs_source.py +0 -0
  34. {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook/sources/webhook_source.py +0 -0
  35. {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook/utils/__init__.py +0 -0
  36. {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook/utils/async_utils.py +0 -0
  37. {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook/utils/container_logs.py +0 -0
  38. {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook/utils/github.py +0 -0
  39. {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook/utils/logging.py +0 -0
  40. {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook/utils/s3_artifacts.py +0 -0
  41. {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook/utils/serialization.py +0 -0
  42. {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook.egg-info/dependency_links.txt +0 -0
  43. {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook.egg-info/entry_points.txt +0 -0
  44. {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook.egg-info/requires.txt +0 -0
  45. {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook.egg-info/top_level.txt +0 -0
  46. {devs_webhook-1.2.0 → devs_webhook-2.0.0}/setup.cfg +0 -0
  47. {devs_webhook-1.2.0 → devs_webhook-2.0.0}/tests/test_allowlist.py +0 -0
  48. {devs_webhook-1.2.0 → devs_webhook-2.0.0}/tests/test_authentication.py +0 -0
  49. {devs_webhook-1.2.0 → devs_webhook-2.0.0}/tests/test_authorized_users.py +0 -0
  50. {devs_webhook-1.2.0 → devs_webhook-2.0.0}/tests/test_ci_container_pool.py +0 -0
  51. {devs_webhook-1.2.0 → devs_webhook-2.0.0}/tests/test_container_logs.py +0 -0
  52. {devs_webhook-1.2.0 → devs_webhook-2.0.0}/tests/test_single_queue.py +0 -0
  53. {devs_webhook-1.2.0 → devs_webhook-2.0.0}/tests/test_sqs_burst.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devs-webhook
3
- Version: 1.2.0
3
+ Version: 2.0.0
4
4
  Summary: GitHub webhook handler for automated devcontainer operations with Claude Code
5
5
  Author: Dan Lester
6
6
  License-Expression: MIT
@@ -3,7 +3,7 @@
3
3
  import os
4
4
  from pathlib import Path
5
5
  from functools import lru_cache
6
- from typing import List, Optional
6
+ from typing import Dict, List, Optional
7
7
  try:
8
8
  from pydantic_settings import BaseSettings, SettingsConfigDict
9
9
  except ImportError:
@@ -84,6 +84,12 @@ class WebhookConfig(BaseSettings, BaseConfig):
84
84
  default=60,
85
85
  description="How often to check for idle/old containers (in seconds)"
86
86
  )
87
+ stop_container_after_task: bool = Field(
88
+ default=True,
89
+ description="Stop container after each task completes. "
90
+ "This ensures only one running container per dev name at any time, "
91
+ "reducing RAM usage when multiple repos are in play."
92
+ )
87
93
  max_concurrent_tasks: int = Field(default=3, description="Maximum concurrent tasks")
88
94
 
89
95
  # Repository settings
@@ -181,7 +187,14 @@ class WebhookConfig(BaseSettings, BaseConfig):
181
187
  if self.log_format == "json":
182
188
  self.log_format = "console"
183
189
  return self
184
-
190
+
191
+ @property
192
+ def container_labels(self) -> Dict[str, str]:
193
+ """Standard labels applied to containers created by webhook."""
194
+ labels = super().container_labels
195
+ labels["devs.source"] = "webhook"
196
+ return labels
197
+
185
198
  # Configuration for Pydantic Settings
186
199
  # Note: .env files are optional - environment variables are the primary source
187
200
  if SettingsConfigDict:
@@ -236,15 +236,22 @@ Always remember to PUSH your work to origin!
236
236
  pr_closing_instruction = ""
237
237
  if not is_pr:
238
238
  pr_closing_instruction = " (mention that it closes an issue number if it does)"
239
-
239
+
240
+ # Add draft PR instruction if enabled in DEVS.yml
241
+ draft_pr_instruction = ""
242
+ if devs_options and devs_options.draft_prs:
243
+ draft_pr_instruction = """
244
+ IMPORTANT: Create pull requests as DRAFTS using `gh pr create --draft`. Keep PRs in draft status until the user explicitly instructs you to mark them ready for review.
245
+ """
246
+
240
247
  # Build unified prompt with variable parts
241
- prompt = f"""You are an AI developer helping build a software project in a GitHub repository.
248
+ prompt = f"""You are an AI developer helping build a software project in a GitHub repository.
242
249
  You have been mentioned in a {event_type_full} and need to take action.
243
250
 
244
- You should ensure you're on the latest commits in the repo's default branch.
245
- Generally work on feature branches for changes.
251
+ You should ensure you're on the latest commits in the repo's default branch.
252
+ Generally work on feature branches for changes.
246
253
  Submit any changes as a pull request when done{pr_closing_instruction}.
247
-
254
+ {draft_pr_instruction}
248
255
  IMPORTANT: Do not close the issue unless the user explicitly instructs you to do so. Even if you implement a solution, leave the issue open for the user to review and close when they're satisfied.
249
256
 
250
257
  If you need to ask for clarification, or if only asked for your thoughts, please respond with a comment on the {event_type}.
@@ -837,10 +837,35 @@ class ContainerPool:
837
837
  # Task execution failed, but we've logged it - don't re-raise
838
838
 
839
839
  finally:
840
- # Update last_used timestamp after task completes (success or failure)
840
+ # Handle container cleanup after task completes (success or failure)
841
841
  async with self._lock:
842
842
  if dev_name in self.running_containers:
843
- self.running_containers[dev_name]["last_used"] = datetime.now(tz=timezone.utc)
843
+ if self.config.stop_container_after_task:
844
+ # Stop container immediately after task completes
845
+ # This ensures only one running container per dev name queue
846
+ # Don't remove the container or workspace - just stop it so it can restart quickly
847
+ info = self.running_containers[dev_name]
848
+ logger.info("Stopping container after task completion",
849
+ container=dev_name,
850
+ repo_path=str(info["repo_path"]),
851
+ stop_container_after_task=True)
852
+ try:
853
+ await self._cleanup_container(
854
+ dev_name,
855
+ info["repo_path"],
856
+ remove_workspace=False,
857
+ remove_container=False
858
+ )
859
+ del self.running_containers[dev_name]
860
+ logger.info("Container stopped successfully after task",
861
+ container=dev_name)
862
+ except Exception as cleanup_error:
863
+ logger.error("Failed to stop container after task",
864
+ container=dev_name,
865
+ error=str(cleanup_error))
866
+ else:
867
+ # Just update last_used timestamp (legacy behavior)
868
+ self.running_containers[dev_name]["last_used"] = datetime.now(tz=timezone.utc)
844
869
 
845
870
  async def _checkout_default_branch(self, repo_name: str, repo_path: Path) -> None:
846
871
  """Checkout the default branch to ensure devcontainer files are from the right branch.
@@ -876,6 +901,8 @@ class ContainerPool:
876
901
  logger.info("Checked out default branch",
877
902
  repo=repo_name,
878
903
  branch=default_branch)
904
+ # Clean untracked files after checkout
905
+ await self._clean_untracked_files(repo_path)
879
906
  elif default_branch == "dev":
880
907
  # dev branch doesn't exist, try main
881
908
  logger.info("Branch 'dev' not found, trying 'main'",
@@ -891,6 +918,8 @@ class ContainerPool:
891
918
  if process.returncode == 0:
892
919
  logger.info("Checked out main branch",
893
920
  repo=repo_name)
921
+ # Clean untracked files after checkout
922
+ await self._clean_untracked_files(repo_path)
894
923
  else:
895
924
  # Both failed, stay on current branch (probably master or main after clone)
896
925
  logger.warning("Could not checkout dev or main branch, staying on current branch",
@@ -903,6 +932,31 @@ class ContainerPool:
903
932
  branch=default_branch,
904
933
  stderr=stderr.decode()[:200] if stderr else "")
905
934
 
935
+ async def _clean_untracked_files(self, repo_path: Path) -> None:
936
+ """Remove untracked files and directories from repository.
937
+
938
+ Important for reusing repocache between tasks to avoid leftover files
939
+ from previous runs affecting the next task.
940
+
941
+ Args:
942
+ repo_path: Path to the repository
943
+ """
944
+ clean_cmd = ["git", "-C", str(repo_path), "clean", "-fd"]
945
+ process = await asyncio.create_subprocess_exec(
946
+ *clean_cmd,
947
+ stdout=asyncio.subprocess.PIPE,
948
+ stderr=asyncio.subprocess.PIPE
949
+ )
950
+ stdout, stderr = await process.communicate()
951
+
952
+ if process.returncode == 0:
953
+ logger.info("Cleaned untracked files from repository",
954
+ repo_path=str(repo_path))
955
+ else:
956
+ logger.warning("Could not clean untracked files",
957
+ repo_path=str(repo_path),
958
+ stderr=stderr.decode()[:200] if stderr else "")
959
+
906
960
  async def _ensure_repository_cloned(
907
961
  self,
908
962
  repo_name: str,
@@ -1211,6 +1265,7 @@ Please check the webhook handler logs for more details, or try mentioning me aga
1211
1265
  "idle_timeout_minutes": self.config.container_timeout_minutes,
1212
1266
  "max_age_hours": self.config.container_max_age_hours,
1213
1267
  "check_interval_seconds": self.config.cleanup_check_interval_seconds,
1268
+ "stop_container_after_task": self.config.stop_container_after_task,
1214
1269
  },
1215
1270
  }
1216
1271
 
@@ -1367,36 +1422,54 @@ Please check the webhook handler logs for more details, or try mentioning me aga
1367
1422
  logger.error("Error in idle cleanup worker", error=str(e))
1368
1423
 
1369
1424
 
1370
- async def _cleanup_container(self, dev_name: str, repo_path: Path) -> None:
1425
+ async def _cleanup_container(
1426
+ self,
1427
+ dev_name: str,
1428
+ repo_path: Path,
1429
+ remove_workspace: bool = True,
1430
+ remove_container: bool = True
1431
+ ) -> None:
1371
1432
  """Clean up a container after use.
1372
-
1433
+
1373
1434
  Args:
1374
1435
  dev_name: Name of container to clean up
1375
1436
  repo_path: Path to repository on host
1437
+ remove_workspace: If True (default), also remove the workspace.
1438
+ If False, only stop the container but keep the workspace
1439
+ for faster reuse on restart.
1440
+ remove_container: If True (default), remove the container after stopping.
1441
+ If False, only stop the container (it can be restarted quickly).
1376
1442
  """
1377
1443
  try:
1378
1444
  # Create project and managers for cleanup
1379
1445
  project = Project(repo_path)
1380
-
1446
+
1381
1447
  # Use the same config as the rest of the webhook handler
1382
1448
  workspace_manager = WorkspaceManager(project, self.config)
1383
1449
  container_manager = ContainerManager(project, self.config)
1384
-
1385
- # Stop container
1386
- logger.info("Starting container stop", container=dev_name)
1387
- stop_success = container_manager.stop_container(dev_name)
1388
- logger.info("Container stop result", container=dev_name, success=stop_success)
1389
-
1390
- # Remove workspace
1391
- logger.info("Starting workspace removal", container=dev_name)
1392
- workspace_success = workspace_manager.remove_workspace(dev_name)
1393
- logger.info("Workspace removal result", container=dev_name, success=workspace_success)
1394
-
1395
- logger.info("Container cleanup complete",
1450
+
1451
+ # Stop container (and optionally remove it)
1452
+ logger.info("Starting container stop", container=dev_name, remove=remove_container)
1453
+ stop_success = container_manager.stop_container(dev_name, remove=remove_container)
1454
+ logger.info("Container stop result", container=dev_name, success=stop_success, removed=remove_container)
1455
+
1456
+ # Remove workspace only if requested
1457
+ workspace_success = True
1458
+ if remove_workspace:
1459
+ logger.info("Starting workspace removal", container=dev_name)
1460
+ workspace_success = workspace_manager.remove_workspace(dev_name)
1461
+ logger.info("Workspace removal result", container=dev_name, success=workspace_success)
1462
+ else:
1463
+ logger.info("Keeping workspace for faster reuse",
1464
+ container=dev_name,
1465
+ workspace_path=str(workspace_manager.get_workspace_dir(dev_name)))
1466
+
1467
+ logger.info("Container cleanup complete",
1396
1468
  container=dev_name,
1397
1469
  container_stopped=stop_success,
1398
- workspace_removed=workspace_success)
1399
-
1470
+ container_removed=remove_container,
1471
+ workspace_removed=workspace_success if remove_workspace else "skipped")
1472
+
1400
1473
  except Exception as e:
1401
1474
  logger.error("Container cleanup failed",
1402
1475
  container=dev_name,
@@ -61,6 +61,7 @@ class GitHubPullRequest(BaseModel):
61
61
  title: str
62
62
  body: Optional[str] = None
63
63
  state: str
64
+ draft: bool = False
64
65
  user: GitHubUser
65
66
  assignee: Optional[GitHubUser] = None
66
67
  html_url: str
@@ -290,15 +290,22 @@ class WebhookParser:
290
290
 
291
291
  # Handle pull request events for CI
292
292
  if isinstance(event, PullRequestEvent):
293
+ # Skip draft PRs - they shouldn't trigger CI runs
294
+ if event.pull_request.draft:
295
+ logger.info("Skipping CI for draft PR",
296
+ pr_number=event.pull_request.number,
297
+ repo=event.repository.full_name)
298
+ return False
299
+
293
300
  # Process PR opened, synchronize (new commits), reopened
294
301
  ci_pr_actions = ["opened", "synchronize", "reopened"]
295
302
  should_process = event.action in ci_pr_actions
296
-
303
+
297
304
  logger.info("PR event CI check",
298
305
  action=event.action,
299
306
  ci_pr_actions=ci_pr_actions,
300
307
  should_process=should_process)
301
-
308
+
302
309
  return should_process
303
310
 
304
311
  # Handle push events for CI
@@ -448,23 +448,25 @@ def test(prompt: str, repo: str, host: str, port: int, username: str, password:
448
448
 
449
449
 
450
450
  @cli.command()
451
- @click.option('--all', 'cleanup_all', is_flag=True, help='Clean up all managed containers (not just idle ones)')
451
+ @click.option('--all', 'cleanup_all', is_flag=True, help='Clean up all webhook containers (not just idle ones)')
452
452
  @click.option('--max-age-hours', default=None, type=int, help='Override max age threshold (default: from config or 10)')
453
453
  @click.option('--idle-minutes', default=None, type=int, help='Override idle timeout (default: from config or 60)')
454
454
  @click.option('--dry-run', is_flag=True, help='Show what would be cleaned up without actually doing it')
455
455
  def cleanup(cleanup_all: bool, max_age_hours: int, idle_minutes: int, dry_run: bool):
456
- """Clean up idle and old containers.
456
+ """Clean up idle and old webhook containers.
457
457
 
458
- This command finds containers managed by devs-webhook and cleans up those that
458
+ This command finds containers created by devs-webhook and cleans up those that
459
459
  are idle (exited, or running but not processing) and either:
460
460
  - Idle for longer than the idle timeout
461
461
  - Older than the max age threshold
462
462
 
463
+ Only cleans up webhook-created containers (not CLI-created ones).
464
+
463
465
  Use this after burst mode processing completes, or via cron for periodic cleanup.
464
466
 
465
467
  Examples:
466
468
  devs-webhook cleanup # Clean up idle containers exceeding thresholds
467
- devs-webhook cleanup --all # Clean up ALL managed containers
469
+ devs-webhook cleanup --all # Clean up ALL webhook containers
468
470
  devs-webhook cleanup --dry-run # Show what would be cleaned up
469
471
  devs-webhook cleanup --max-age-hours 2 # Override max age to 2 hours
470
472
  """
@@ -487,7 +489,7 @@ def cleanup(cleanup_all: bool, max_age_hours: int, idle_minutes: int, dry_run: b
487
489
  click.echo(f" Max age: {max_age.total_seconds() / 3600:.1f} hours")
488
490
  click.echo(f" Idle timeout: {idle_timeout.total_seconds() / 60:.0f} minutes")
489
491
  if cleanup_all:
490
- click.echo(" Mode: Clean ALL managed containers")
492
+ click.echo(" Mode: Clean ALL webhook containers")
491
493
  if dry_run:
492
494
  click.echo(" Mode: DRY RUN (no changes will be made)")
493
495
  click.echo()
@@ -498,18 +500,21 @@ def cleanup(cleanup_all: bool, max_age_hours: int, idle_minutes: int, dry_run: b
498
500
  click.echo(f"❌ Failed to connect to Docker: {e}")
499
501
  return
500
502
 
501
- # Find all devs-managed containers
503
+ # Find webhook-created containers only
502
504
  try:
503
- containers = docker.find_containers_by_labels({"devs.managed": "true"})
505
+ containers = docker.find_containers_by_labels({
506
+ "devs.managed": "true",
507
+ "devs.source": "webhook"
508
+ })
504
509
  except Exception as e:
505
510
  click.echo(f"❌ Failed to list containers: {e}")
506
511
  return
507
512
 
508
513
  if not containers:
509
- click.echo("✅ No managed containers found")
514
+ click.echo("✅ No webhook containers found")
510
515
  return
511
516
 
512
- click.echo(f"Found {len(containers)} managed container(s)")
517
+ click.echo(f"Found {len(containers)} webhook container(s)")
513
518
 
514
519
  now = datetime.now(tz=timezone.utc)
515
520
  cleaned = 0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devs-webhook
3
- Version: 1.2.0
3
+ Version: 2.0.0
4
4
  Summary: GitHub webhook handler for automated devcontainer operations with Claude Code
5
5
  Author: Dan Lester
6
6
  License-Expression: MIT
@@ -43,7 +43,9 @@ tests/test_allowlist.py
43
43
  tests/test_authentication.py
44
44
  tests/test_authorized_users.py
45
45
  tests/test_ci_container_pool.py
46
+ tests/test_cleanup_mode.py
46
47
  tests/test_container_logs.py
47
48
  tests/test_single_queue.py
48
49
  tests/test_sqs_burst.py
50
+ tests/test_stop_container_after_task.py
49
51
  tests/test_webhook_parser.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "devs-webhook"
7
- version = "1.2.0"
7
+ version = "2.0.0"
8
8
  description = "GitHub webhook handler for automated devcontainer operations with Claude Code"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -0,0 +1,165 @@
1
+ """Tests for container cleanup behavior.
2
+
3
+ The cleanup behavior supports two modes:
4
+ - remove_workspace=True (default): Full cleanup - stops container AND removes workspace
5
+ - remove_workspace=False: Stop-only cleanup - stops container but keeps workspace for faster reuse
6
+
7
+ The stop-only mode is useful for the stop-after-task workflow where containers are
8
+ stopped after each task but need to restart quickly for the next task on the same repository.
9
+ """
10
+
11
+ import asyncio
12
+ import pytest
13
+ import tempfile
14
+ from datetime import datetime, timezone
15
+ from pathlib import Path
16
+ from unittest.mock import MagicMock, patch, AsyncMock
17
+
18
+ from devs_webhook.core.container_pool import ContainerPool
19
+
20
+
21
+ @pytest.fixture
22
+ def mock_config():
23
+ """Create a mock configuration for cleanup tests."""
24
+ config = MagicMock()
25
+ config.get_container_pool_list.return_value = ["eamonn", "harry"]
26
+ config.get_ci_container_pool_list.return_value = ["eamonn", "harry"]
27
+ config.has_separate_ci_pool.return_value = False
28
+ config.github_token = "test-token-1234567890"
29
+ config.container_timeout_minutes = 60
30
+ config.container_max_age_hours = 10
31
+ config.cleanup_check_interval_seconds = 60
32
+ config.worker_logs_enabled = False
33
+ temp_dir = tempfile.mkdtemp()
34
+ config.repo_cache_dir = Path(temp_dir)
35
+ return config
36
+
37
+
38
+ class TestCleanupContainerPool:
39
+ """Tests for cleanup behavior in ContainerPool."""
40
+
41
+ @pytest.mark.asyncio
42
+ async def test_cleanup_default_removes_workspace(self, mock_config):
43
+ """Test that _cleanup_container by default stops container AND removes workspace."""
44
+ with patch('devs_webhook.core.container_pool.get_config', return_value=mock_config):
45
+ pool = ContainerPool()
46
+
47
+ # Cancel workers to avoid background task issues
48
+ for worker in pool.container_workers.values():
49
+ worker.cancel()
50
+ if pool.cleanup_worker:
51
+ pool.cleanup_worker.cancel()
52
+
53
+ # Mock the managers
54
+ mock_container_manager = MagicMock()
55
+ mock_container_manager.stop_container.return_value = True
56
+ mock_workspace_manager = MagicMock()
57
+ mock_workspace_manager.remove_workspace.return_value = True
58
+
59
+ with patch('devs_webhook.core.container_pool.ContainerManager', return_value=mock_container_manager), \
60
+ patch('devs_webhook.core.container_pool.WorkspaceManager', return_value=mock_workspace_manager), \
61
+ patch('devs_webhook.core.container_pool.Project'):
62
+
63
+ await pool._cleanup_container("eamonn", Path("/tmp/test-repo"))
64
+
65
+ # Verify container was stopped AND removed (default: remove=True)
66
+ mock_container_manager.stop_container.assert_called_once_with("eamonn", remove=True)
67
+ # Verify workspace WAS removed (default behavior)
68
+ mock_workspace_manager.remove_workspace.assert_called_once_with("eamonn")
69
+
70
+ @pytest.mark.asyncio
71
+ async def test_cleanup_stop_only_keeps_workspace(self, mock_config):
72
+ """Test that _cleanup_container with remove_workspace=False and remove_container=False keeps both."""
73
+ with patch('devs_webhook.core.container_pool.get_config', return_value=mock_config):
74
+ pool = ContainerPool()
75
+
76
+ # Cancel workers to avoid background task issues
77
+ for worker in pool.container_workers.values():
78
+ worker.cancel()
79
+ if pool.cleanup_worker:
80
+ pool.cleanup_worker.cancel()
81
+
82
+ # Mock the managers
83
+ mock_container_manager = MagicMock()
84
+ mock_container_manager.stop_container.return_value = True
85
+ mock_workspace_manager = MagicMock()
86
+ mock_workspace_manager.get_workspace_dir.return_value = Path("/tmp/workspace/eamonn")
87
+
88
+ with patch('devs_webhook.core.container_pool.ContainerManager', return_value=mock_container_manager), \
89
+ patch('devs_webhook.core.container_pool.WorkspaceManager', return_value=mock_workspace_manager), \
90
+ patch('devs_webhook.core.container_pool.Project'):
91
+
92
+ await pool._cleanup_container("eamonn", Path("/tmp/test-repo"), remove_workspace=False, remove_container=False)
93
+
94
+ # Verify container was stopped but NOT removed (remove=False)
95
+ mock_container_manager.stop_container.assert_called_once_with("eamonn", remove=False)
96
+ # Verify workspace was NOT removed (kept for reuse)
97
+ mock_workspace_manager.remove_workspace.assert_not_called()
98
+ # Verify get_workspace_dir was called for logging
99
+ mock_workspace_manager.get_workspace_dir.assert_called_once_with("eamonn")
100
+
101
+ @pytest.mark.asyncio
102
+ async def test_cleanup_preserves_workspace_for_reuse(self, mock_config):
103
+ """Test that workspace and container are preserved across multiple cleanups with stop-only mode."""
104
+ with patch('devs_webhook.core.container_pool.get_config', return_value=mock_config):
105
+ pool = ContainerPool()
106
+
107
+ # Cancel workers
108
+ for worker in pool.container_workers.values():
109
+ worker.cancel()
110
+ if pool.cleanup_worker:
111
+ pool.cleanup_worker.cancel()
112
+
113
+ dev_name = "eamonn"
114
+ repo_path = Path("/tmp/test-repo")
115
+
116
+ # Mock the managers
117
+ mock_container_manager = MagicMock()
118
+ mock_container_manager.stop_container.return_value = True
119
+ mock_workspace_manager = MagicMock()
120
+ mock_workspace_manager.get_workspace_dir.return_value = Path("/tmp/workspace/eamonn")
121
+
122
+ with patch('devs_webhook.core.container_pool.ContainerManager', return_value=mock_container_manager), \
123
+ patch('devs_webhook.core.container_pool.WorkspaceManager', return_value=mock_workspace_manager), \
124
+ patch('devs_webhook.core.container_pool.Project'):
125
+
126
+ # First cleanup with stop-only (don't remove container or workspace)
127
+ await pool._cleanup_container(dev_name, repo_path, remove_workspace=False, remove_container=False)
128
+
129
+ # Verify container stopped (but not removed) and workspace kept
130
+ mock_container_manager.stop_container.assert_called_with(dev_name, remove=False)
131
+ assert mock_container_manager.stop_container.call_count == 1
132
+ assert mock_workspace_manager.remove_workspace.call_count == 0
133
+
134
+ # Reset mocks for second cleanup
135
+ mock_container_manager.reset_mock()
136
+ mock_workspace_manager.reset_mock()
137
+
138
+ # Second cleanup (simulating another task on same container)
139
+ await pool._cleanup_container(dev_name, repo_path, remove_workspace=False, remove_container=False)
140
+
141
+ # Again, container stopped (not removed) and workspace still kept
142
+ mock_container_manager.stop_container.assert_called_with(dev_name, remove=False)
143
+ assert mock_container_manager.stop_container.call_count == 1
144
+ assert mock_workspace_manager.remove_workspace.call_count == 0
145
+
146
+ @pytest.mark.asyncio
147
+ async def test_status_includes_cleanup_settings(self, mock_config):
148
+ """Test that status endpoint includes cleanup settings."""
149
+ with patch('devs_webhook.core.container_pool.get_config', return_value=mock_config):
150
+ pool = ContainerPool()
151
+
152
+ # Cancel workers to avoid background task issues
153
+ for worker in pool.container_workers.values():
154
+ worker.cancel()
155
+ if pool.cleanup_worker:
156
+ pool.cleanup_worker.cancel()
157
+
158
+ status = await pool.get_status()
159
+
160
+ assert "cleanup_settings" in status
161
+ assert "idle_timeout_minutes" in status["cleanup_settings"]
162
+ assert "max_age_hours" in status["cleanup_settings"]
163
+ assert "check_interval_seconds" in status["cleanup_settings"]
164
+ assert status["cleanup_settings"]["idle_timeout_minutes"] == 60
165
+ assert status["cleanup_settings"]["max_age_hours"] == 10
@@ -0,0 +1,348 @@
1
+ """Tests for stop_container_after_task functionality."""
2
+
3
+ import asyncio
4
+ import pytest
5
+ import tempfile
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+ from unittest.mock import MagicMock, patch, AsyncMock
9
+
10
+ from devs_webhook.core.container_pool import ContainerPool, QueuedTask
11
+ from devs_webhook.github.models import (
12
+ WebhookEvent, GitHubRepository, GitHubUser, IssueEvent, GitHubIssue
13
+ )
14
+ from devs_common.devs_config import DevsOptions
15
+
16
+
17
+ @pytest.fixture
18
+ def mock_config_stop_after_task():
19
+ """Create a mock configuration with stop_container_after_task enabled."""
20
+ config = MagicMock()
21
+ config.get_container_pool_list.return_value = ["eamonn", "harry"]
22
+ config.get_ci_container_pool_list.return_value = ["eamonn", "harry"]
23
+ config.has_separate_ci_pool.return_value = False
24
+ config.github_token = "test-token-1234567890"
25
+ config.container_timeout_minutes = 60
26
+ config.container_max_age_hours = 10
27
+ config.cleanup_check_interval_seconds = 60
28
+ config.stop_container_after_task = True # Enabled
29
+ config.worker_logs_enabled = False
30
+ temp_dir = tempfile.mkdtemp()
31
+ config.repo_cache_dir = Path(temp_dir)
32
+ return config
33
+
34
+
35
+ @pytest.fixture
36
+ def mock_config_no_stop_after_task():
37
+ """Create a mock configuration with stop_container_after_task disabled."""
38
+ config = MagicMock()
39
+ config.get_container_pool_list.return_value = ["eamonn", "harry"]
40
+ config.get_ci_container_pool_list.return_value = ["eamonn", "harry"]
41
+ config.has_separate_ci_pool.return_value = False
42
+ config.github_token = "test-token-1234567890"
43
+ config.container_timeout_minutes = 60
44
+ config.container_max_age_hours = 10
45
+ config.cleanup_check_interval_seconds = 60
46
+ config.stop_container_after_task = False # Disabled (legacy behavior)
47
+ config.worker_logs_enabled = False
48
+ temp_dir = tempfile.mkdtemp()
49
+ config.repo_cache_dir = Path(temp_dir)
50
+ return config
51
+
52
+
53
+ @pytest.fixture
54
+ def mock_event():
55
+ """Create a mock webhook event."""
56
+ return IssueEvent(
57
+ action="opened",
58
+ repository=GitHubRepository(
59
+ id=1,
60
+ name="test-repo",
61
+ full_name="test-org/test-repo",
62
+ owner=GitHubUser(
63
+ login="test-org",
64
+ id=1,
65
+ avatar_url="https://example.com/avatar",
66
+ html_url="https://example.com/user"
67
+ ),
68
+ html_url="https://github.com/test-org/test-repo",
69
+ clone_url="https://github.com/test-org/test-repo.git",
70
+ ssh_url="git@github.com:test-org/test-repo.git",
71
+ default_branch="main"
72
+ ),
73
+ sender=GitHubUser(
74
+ login="sender",
75
+ id=2,
76
+ avatar_url="https://example.com/avatar2",
77
+ html_url="https://example.com/user2"
78
+ ),
79
+ issue=GitHubIssue(
80
+ id=1,
81
+ number=42,
82
+ title="Test Issue",
83
+ body="Test body",
84
+ state="open",
85
+ user=GitHubUser(
86
+ login="sender",
87
+ id=2,
88
+ avatar_url="https://example.com/avatar2",
89
+ html_url="https://example.com/user2"
90
+ ),
91
+ html_url="https://github.com/test-org/test-repo/issues/42",
92
+ created_at="2024-01-01T00:00:00Z",
93
+ updated_at="2024-01-01T00:00:00Z"
94
+ )
95
+ )
96
+
97
+
98
+ @pytest.mark.asyncio
99
+ async def test_status_includes_stop_container_after_task(mock_config_stop_after_task):
100
+ """Test that status endpoint includes stop_container_after_task setting."""
101
+ with patch('devs_webhook.core.container_pool.get_config', return_value=mock_config_stop_after_task):
102
+ pool = ContainerPool()
103
+
104
+ # Cancel workers
105
+ for worker in pool.container_workers.values():
106
+ worker.cancel()
107
+ if pool.cleanup_worker:
108
+ pool.cleanup_worker.cancel()
109
+
110
+ status = await pool.get_status()
111
+
112
+ assert "cleanup_settings" in status
113
+ assert "stop_container_after_task" in status["cleanup_settings"]
114
+ assert status["cleanup_settings"]["stop_container_after_task"] is True
115
+
116
+
117
+ @pytest.mark.asyncio
118
+ async def test_status_includes_stop_container_after_task_disabled(mock_config_no_stop_after_task):
119
+ """Test that status shows stop_container_after_task as false when disabled."""
120
+ with patch('devs_webhook.core.container_pool.get_config', return_value=mock_config_no_stop_after_task):
121
+ pool = ContainerPool()
122
+
123
+ # Cancel workers
124
+ for worker in pool.container_workers.values():
125
+ worker.cancel()
126
+ if pool.cleanup_worker:
127
+ pool.cleanup_worker.cancel()
128
+
129
+ status = await pool.get_status()
130
+
131
+ assert status["cleanup_settings"]["stop_container_after_task"] is False
132
+
133
+
134
+ @pytest.mark.asyncio
135
+ async def test_container_stopped_after_task_when_enabled(mock_config_stop_after_task):
136
+ """Test that container is stopped after task when stop_container_after_task is enabled."""
137
+ with patch('devs_webhook.core.container_pool.get_config', return_value=mock_config_stop_after_task):
138
+ pool = ContainerPool()
139
+
140
+ # Cancel workers to prevent actual task processing
141
+ for worker in pool.container_workers.values():
142
+ worker.cancel()
143
+ if pool.cleanup_worker:
144
+ pool.cleanup_worker.cancel()
145
+
146
+ # Simulate a running container being tracked
147
+ dev_name = "eamonn"
148
+ repo_path = Path("/tmp/test-repo")
149
+ now = datetime.now(tz=timezone.utc)
150
+ pool.running_containers[dev_name] = {
151
+ "repo_path": repo_path,
152
+ "started_at": now,
153
+ "last_used": now,
154
+ }
155
+
156
+ # Mock the _cleanup_container method
157
+ pool._cleanup_container = AsyncMock()
158
+
159
+ # Simulate the finally block logic from _process_task_subprocess
160
+ async with pool._lock:
161
+ if dev_name in pool.running_containers:
162
+ if pool.config.stop_container_after_task:
163
+ info = pool.running_containers[dev_name]
164
+ # When stop_container_after_task=True, we stop but don't remove
165
+ # container or workspace for faster restart
166
+ await pool._cleanup_container(
167
+ dev_name,
168
+ info["repo_path"],
169
+ remove_workspace=False,
170
+ remove_container=False
171
+ )
172
+ del pool.running_containers[dev_name]
173
+
174
+ # Verify cleanup was called with stop-only parameters
175
+ pool._cleanup_container.assert_called_once_with(
176
+ dev_name, repo_path, remove_workspace=False, remove_container=False
177
+ )
178
+ # Verify container was removed from tracking
179
+ assert dev_name not in pool.running_containers
180
+
181
+
182
+ @pytest.mark.asyncio
183
+ async def test_container_not_stopped_when_disabled(mock_config_no_stop_after_task):
184
+ """Test that container is NOT stopped after task when stop_container_after_task is disabled."""
185
+ with patch('devs_webhook.core.container_pool.get_config', return_value=mock_config_no_stop_after_task):
186
+ pool = ContainerPool()
187
+
188
+ # Cancel workers
189
+ for worker in pool.container_workers.values():
190
+ worker.cancel()
191
+ if pool.cleanup_worker:
192
+ pool.cleanup_worker.cancel()
193
+
194
+ # Simulate a running container being tracked
195
+ dev_name = "eamonn"
196
+ repo_path = Path("/tmp/test-repo")
197
+ initial_time = datetime.now(tz=timezone.utc)
198
+ pool.running_containers[dev_name] = {
199
+ "repo_path": repo_path,
200
+ "started_at": initial_time,
201
+ "last_used": initial_time,
202
+ }
203
+
204
+ # Mock the _cleanup_container method
205
+ pool._cleanup_container = AsyncMock()
206
+
207
+ # Simulate the finally block logic from _process_task_subprocess
208
+ async with pool._lock:
209
+ if dev_name in pool.running_containers:
210
+ if pool.config.stop_container_after_task:
211
+ info = pool.running_containers[dev_name]
212
+ await pool._cleanup_container(dev_name, info["repo_path"])
213
+ del pool.running_containers[dev_name]
214
+ else:
215
+ # Just update last_used timestamp (legacy behavior)
216
+ pool.running_containers[dev_name]["last_used"] = datetime.now(tz=timezone.utc)
217
+
218
+ # Verify cleanup was NOT called
219
+ pool._cleanup_container.assert_not_called()
220
+ # Verify container is still being tracked
221
+ assert dev_name in pool.running_containers
222
+ # Verify last_used was updated
223
+ assert pool.running_containers[dev_name]["last_used"] > initial_time
224
+
225
+
226
+ @pytest.mark.asyncio
227
+ async def test_only_one_running_container_per_dev_name(mock_config_stop_after_task):
228
+ """Test that stop_container_after_task ensures only one container per dev name.
229
+
230
+ When enabled, after each task completes the container is stopped, so the next
231
+ task in the same queue will start a fresh container.
232
+ """
233
+ with patch('devs_webhook.core.container_pool.get_config', return_value=mock_config_stop_after_task):
234
+ pool = ContainerPool()
235
+
236
+ # Cancel workers
237
+ for worker in pool.container_workers.values():
238
+ worker.cancel()
239
+ if pool.cleanup_worker:
240
+ pool.cleanup_worker.cancel()
241
+
242
+ dev_name = "eamonn"
243
+ repo_path_1 = Path("/tmp/test-repo-1")
244
+ repo_path_2 = Path("/tmp/test-repo-2")
245
+
246
+ # Mock cleanup
247
+ pool._cleanup_container = AsyncMock()
248
+
249
+ # Simulate first task starting
250
+ now = datetime.now(tz=timezone.utc)
251
+ pool.running_containers[dev_name] = {
252
+ "repo_path": repo_path_1,
253
+ "started_at": now,
254
+ "last_used": now,
255
+ }
256
+
257
+ # Simulate first task completing (with stop after task)
258
+ async with pool._lock:
259
+ if dev_name in pool.running_containers and pool.config.stop_container_after_task:
260
+ await pool._cleanup_container(
261
+ dev_name,
262
+ pool.running_containers[dev_name]["repo_path"],
263
+ remove_workspace=False,
264
+ remove_container=False
265
+ )
266
+ del pool.running_containers[dev_name]
267
+
268
+ # Container should be stopped
269
+ assert dev_name not in pool.running_containers
270
+ pool._cleanup_container.assert_called_once_with(
271
+ dev_name, repo_path_1, remove_workspace=False, remove_container=False
272
+ )
273
+
274
+ # Reset mock
275
+ pool._cleanup_container.reset_mock()
276
+
277
+ # Simulate second task starting (different repo)
278
+ now = datetime.now(tz=timezone.utc)
279
+ pool.running_containers[dev_name] = {
280
+ "repo_path": repo_path_2,
281
+ "started_at": now,
282
+ "last_used": now,
283
+ }
284
+
285
+ # Simulate second task completing
286
+ async with pool._lock:
287
+ if dev_name in pool.running_containers and pool.config.stop_container_after_task:
288
+ await pool._cleanup_container(
289
+ dev_name,
290
+ pool.running_containers[dev_name]["repo_path"],
291
+ remove_workspace=False,
292
+ remove_container=False
293
+ )
294
+ del pool.running_containers[dev_name]
295
+
296
+ # Container should be stopped again
297
+ assert dev_name not in pool.running_containers
298
+ pool._cleanup_container.assert_called_once_with(
299
+ dev_name, repo_path_2, remove_workspace=False, remove_container=False
300
+ )
301
+
302
+
303
+ @pytest.mark.asyncio
304
+ async def test_cleanup_error_handling(mock_config_stop_after_task):
305
+ """Test that cleanup errors are handled gracefully and don't crash the pool."""
306
+ with patch('devs_webhook.core.container_pool.get_config', return_value=mock_config_stop_after_task):
307
+ pool = ContainerPool()
308
+
309
+ # Cancel workers
310
+ for worker in pool.container_workers.values():
311
+ worker.cancel()
312
+ if pool.cleanup_worker:
313
+ pool.cleanup_worker.cancel()
314
+
315
+ dev_name = "eamonn"
316
+ repo_path = Path("/tmp/test-repo")
317
+ now = datetime.now(tz=timezone.utc)
318
+ pool.running_containers[dev_name] = {
319
+ "repo_path": repo_path,
320
+ "started_at": now,
321
+ "last_used": now,
322
+ }
323
+
324
+ # Mock cleanup to raise an exception
325
+ pool._cleanup_container = AsyncMock(side_effect=Exception("Cleanup failed"))
326
+
327
+ # Simulate the finally block - should not raise
328
+ try:
329
+ async with pool._lock:
330
+ if dev_name in pool.running_containers:
331
+ if pool.config.stop_container_after_task:
332
+ info = pool.running_containers[dev_name]
333
+ try:
334
+ await pool._cleanup_container(
335
+ dev_name,
336
+ info["repo_path"],
337
+ remove_workspace=False,
338
+ remove_container=False
339
+ )
340
+ del pool.running_containers[dev_name]
341
+ except Exception:
342
+ # Error is logged but not re-raised
343
+ pass
344
+ except Exception as e:
345
+ pytest.fail(f"Cleanup error should be handled gracefully, but got: {e}")
346
+
347
+ # Container should still be tracked since cleanup failed
348
+ assert dev_name in pool.running_containers
@@ -3,7 +3,8 @@
3
3
  import json
4
4
  import pytest
5
5
  from devs_webhook.github.parser import WebhookParser
6
- from devs_webhook.github.models import IssueEvent, CommentEvent
6
+ from devs_webhook.github.models import IssueEvent, CommentEvent, PullRequestEvent
7
+ from devs_common.devs_config import DevsOptions
7
8
 
8
9
 
9
10
  class TestWebhookParser:
@@ -172,7 +173,123 @@ class TestWebhookParser:
172
173
  payload = {"action": "test"}
173
174
  headers = {"x-github-event": "unsupported"}
174
175
  payload_bytes = json.dumps(payload).encode()
175
-
176
+
176
177
  event = WebhookParser.parse_webhook(headers, payload_bytes)
177
178
  assert event is None
178
-
179
+
180
+
181
+ class TestCIProcessing:
182
+ """Test CI processing functionality."""
183
+
184
+ def _create_pr_payload(self, draft: bool = False, action: str = "opened"):
185
+ """Helper to create a PR payload."""
186
+ return {
187
+ "action": action,
188
+ "pull_request": {
189
+ "id": 1,
190
+ "number": 42,
191
+ "title": "Test PR",
192
+ "body": "Test PR description",
193
+ "state": "open",
194
+ "draft": draft,
195
+ "user": {
196
+ "login": "developer",
197
+ "id": 123,
198
+ "avatar_url": "https://github.com/avatar.jpg",
199
+ "html_url": "https://github.com/developer"
200
+ },
201
+ "assignee": None,
202
+ "html_url": "https://github.com/test/repo/pull/42",
203
+ "head": {"ref": "feature-branch", "sha": "abc123"},
204
+ "base": {"ref": "main", "sha": "def456"},
205
+ "created_at": "2023-01-01T00:00:00Z",
206
+ "updated_at": "2023-01-01T00:00:00Z"
207
+ },
208
+ "repository": {
209
+ "id": 789,
210
+ "name": "repo",
211
+ "full_name": "test/repo",
212
+ "owner": {
213
+ "login": "test",
214
+ "id": 111,
215
+ "avatar_url": "https://github.com/avatar.jpg",
216
+ "html_url": "https://github.com/test"
217
+ },
218
+ "html_url": "https://github.com/test/repo",
219
+ "clone_url": "https://github.com/test/repo.git",
220
+ "ssh_url": "git@github.com:test/repo.git",
221
+ "default_branch": "main"
222
+ },
223
+ "sender": {
224
+ "login": "developer",
225
+ "id": 123,
226
+ "avatar_url": "https://github.com/avatar.jpg",
227
+ "html_url": "https://github.com/developer"
228
+ }
229
+ }
230
+
231
+ def test_ci_skipped_for_draft_pr(self):
232
+ """Test that CI is skipped for draft PRs."""
233
+ payload = self._create_pr_payload(draft=True, action="opened")
234
+ headers = {"x-github-event": "pull_request"}
235
+ payload_bytes = json.dumps(payload).encode()
236
+
237
+ event = WebhookParser.parse_webhook(headers, payload_bytes)
238
+ devs_options = DevsOptions(ci_enabled=True)
239
+
240
+ should_process = WebhookParser.should_process_event_for_ci(event, devs_options)
241
+
242
+ assert should_process is False
243
+
244
+ def test_ci_runs_for_non_draft_pr(self):
245
+ """Test that CI runs for non-draft PRs."""
246
+ payload = self._create_pr_payload(draft=False, action="opened")
247
+ headers = {"x-github-event": "pull_request"}
248
+ payload_bytes = json.dumps(payload).encode()
249
+
250
+ event = WebhookParser.parse_webhook(headers, payload_bytes)
251
+ devs_options = DevsOptions(ci_enabled=True)
252
+
253
+ should_process = WebhookParser.should_process_event_for_ci(event, devs_options)
254
+
255
+ assert should_process is True
256
+
257
+ def test_ci_skipped_for_draft_pr_synchronize(self):
258
+ """Test that CI is skipped for draft PRs on synchronize action."""
259
+ payload = self._create_pr_payload(draft=True, action="synchronize")
260
+ headers = {"x-github-event": "pull_request"}
261
+ payload_bytes = json.dumps(payload).encode()
262
+
263
+ event = WebhookParser.parse_webhook(headers, payload_bytes)
264
+ devs_options = DevsOptions(ci_enabled=True)
265
+
266
+ should_process = WebhookParser.should_process_event_for_ci(event, devs_options)
267
+
268
+ assert should_process is False
269
+
270
+ def test_ci_runs_for_non_draft_pr_synchronize(self):
271
+ """Test that CI runs for non-draft PRs on synchronize action."""
272
+ payload = self._create_pr_payload(draft=False, action="synchronize")
273
+ headers = {"x-github-event": "pull_request"}
274
+ payload_bytes = json.dumps(payload).encode()
275
+
276
+ event = WebhookParser.parse_webhook(headers, payload_bytes)
277
+ devs_options = DevsOptions(ci_enabled=True)
278
+
279
+ should_process = WebhookParser.should_process_event_for_ci(event, devs_options)
280
+
281
+ assert should_process is True
282
+
283
+ def test_ci_not_processed_when_disabled(self):
284
+ """Test that CI is not processed when ci_enabled is False."""
285
+ payload = self._create_pr_payload(draft=False, action="opened")
286
+ headers = {"x-github-event": "pull_request"}
287
+ payload_bytes = json.dumps(payload).encode()
288
+
289
+ event = WebhookParser.parse_webhook(headers, payload_bytes)
290
+ devs_options = DevsOptions(ci_enabled=False)
291
+
292
+ should_process = WebhookParser.should_process_event_for_ci(event, devs_options)
293
+
294
+ assert should_process is False
295
+
File without changes
File without changes
File without changes