devs-webhook 1.2.1__tar.gz → 2.0.1__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.
- {devs_webhook-1.2.1 → devs_webhook-2.0.1}/PKG-INFO +1 -1
- {devs_webhook-1.2.1 → devs_webhook-2.0.1}/devs_webhook/config.py +6 -0
- {devs_webhook-1.2.1 → devs_webhook-2.0.1}/devs_webhook/core/claude_dispatcher.py +12 -5
- {devs_webhook-1.2.1 → devs_webhook-2.0.1}/devs_webhook/core/container_pool.py +124 -19
- {devs_webhook-1.2.1 → devs_webhook-2.0.1}/devs_webhook/github/models.py +1 -0
- {devs_webhook-1.2.1 → devs_webhook-2.0.1}/devs_webhook/github/parser.py +9 -2
- {devs_webhook-1.2.1 → devs_webhook-2.0.1}/devs_webhook.egg-info/PKG-INFO +1 -1
- {devs_webhook-1.2.1 → devs_webhook-2.0.1}/devs_webhook.egg-info/SOURCES.txt +2 -0
- {devs_webhook-1.2.1 → devs_webhook-2.0.1}/pyproject.toml +1 -1
- devs_webhook-2.0.1/tests/test_cleanup_mode.py +165 -0
- devs_webhook-2.0.1/tests/test_stop_container_after_task.py +348 -0
- {devs_webhook-1.2.1 → devs_webhook-2.0.1}/tests/test_webhook_parser.py +120 -3
- {devs_webhook-1.2.1 → devs_webhook-2.0.1}/LICENSE +0 -0
- {devs_webhook-1.2.1 → devs_webhook-2.0.1}/README.md +0 -0
- {devs_webhook-1.2.1 → devs_webhook-2.0.1}/devs_webhook/__init__.py +0 -0
- {devs_webhook-1.2.1 → devs_webhook-2.0.1}/devs_webhook/app.py +0 -0
- {devs_webhook-1.2.1 → devs_webhook-2.0.1}/devs_webhook/cli/__init__.py +0 -0
- {devs_webhook-1.2.1 → devs_webhook-2.0.1}/devs_webhook/cli/worker.py +0 -0
- {devs_webhook-1.2.1 → devs_webhook-2.0.1}/devs_webhook/core/__init__.py +0 -0
- {devs_webhook-1.2.1 → devs_webhook-2.0.1}/devs_webhook/core/base_dispatcher.py +0 -0
- {devs_webhook-1.2.1 → devs_webhook-2.0.1}/devs_webhook/core/deduplication.py +0 -0
- {devs_webhook-1.2.1 → devs_webhook-2.0.1}/devs_webhook/core/repository_manager.py +0 -0
- {devs_webhook-1.2.1 → devs_webhook-2.0.1}/devs_webhook/core/task_processor.py +0 -0
- {devs_webhook-1.2.1 → devs_webhook-2.0.1}/devs_webhook/core/test_dispatcher.py +0 -0
- {devs_webhook-1.2.1 → devs_webhook-2.0.1}/devs_webhook/core/webhook_config.py +0 -0
- {devs_webhook-1.2.1 → devs_webhook-2.0.1}/devs_webhook/core/webhook_handler.py +0 -0
- {devs_webhook-1.2.1 → devs_webhook-2.0.1}/devs_webhook/github/__init__.py +0 -0
- {devs_webhook-1.2.1 → devs_webhook-2.0.1}/devs_webhook/github/app_auth.py +0 -0
- {devs_webhook-1.2.1 → devs_webhook-2.0.1}/devs_webhook/github/client.py +0 -0
- {devs_webhook-1.2.1 → devs_webhook-2.0.1}/devs_webhook/main_cli.py +0 -0
- {devs_webhook-1.2.1 → devs_webhook-2.0.1}/devs_webhook/sources/__init__.py +0 -0
- {devs_webhook-1.2.1 → devs_webhook-2.0.1}/devs_webhook/sources/base.py +0 -0
- {devs_webhook-1.2.1 → devs_webhook-2.0.1}/devs_webhook/sources/sqs_source.py +0 -0
- {devs_webhook-1.2.1 → devs_webhook-2.0.1}/devs_webhook/sources/webhook_source.py +0 -0
- {devs_webhook-1.2.1 → devs_webhook-2.0.1}/devs_webhook/utils/__init__.py +0 -0
- {devs_webhook-1.2.1 → devs_webhook-2.0.1}/devs_webhook/utils/async_utils.py +0 -0
- {devs_webhook-1.2.1 → devs_webhook-2.0.1}/devs_webhook/utils/container_logs.py +0 -0
- {devs_webhook-1.2.1 → devs_webhook-2.0.1}/devs_webhook/utils/github.py +0 -0
- {devs_webhook-1.2.1 → devs_webhook-2.0.1}/devs_webhook/utils/logging.py +0 -0
- {devs_webhook-1.2.1 → devs_webhook-2.0.1}/devs_webhook/utils/s3_artifacts.py +0 -0
- {devs_webhook-1.2.1 → devs_webhook-2.0.1}/devs_webhook/utils/serialization.py +0 -0
- {devs_webhook-1.2.1 → devs_webhook-2.0.1}/devs_webhook.egg-info/dependency_links.txt +0 -0
- {devs_webhook-1.2.1 → devs_webhook-2.0.1}/devs_webhook.egg-info/entry_points.txt +0 -0
- {devs_webhook-1.2.1 → devs_webhook-2.0.1}/devs_webhook.egg-info/requires.txt +0 -0
- {devs_webhook-1.2.1 → devs_webhook-2.0.1}/devs_webhook.egg-info/top_level.txt +0 -0
- {devs_webhook-1.2.1 → devs_webhook-2.0.1}/setup.cfg +0 -0
- {devs_webhook-1.2.1 → devs_webhook-2.0.1}/tests/test_allowlist.py +0 -0
- {devs_webhook-1.2.1 → devs_webhook-2.0.1}/tests/test_authentication.py +0 -0
- {devs_webhook-1.2.1 → devs_webhook-2.0.1}/tests/test_authorized_users.py +0 -0
- {devs_webhook-1.2.1 → devs_webhook-2.0.1}/tests/test_ci_container_pool.py +0 -0
- {devs_webhook-1.2.1 → devs_webhook-2.0.1}/tests/test_container_logs.py +0 -0
- {devs_webhook-1.2.1 → devs_webhook-2.0.1}/tests/test_single_queue.py +0 -0
- {devs_webhook-1.2.1 → devs_webhook-2.0.1}/tests/test_sqs_burst.py +0 -0
|
@@ -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
|
|
@@ -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
|
-
#
|
|
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.
|
|
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,10 @@ class ContainerPool:
|
|
|
876
901
|
logger.info("Checked out default branch",
|
|
877
902
|
repo=repo_name,
|
|
878
903
|
branch=default_branch)
|
|
904
|
+
# Reset to match remote branch so we pick up fetched changes
|
|
905
|
+
await self._reset_to_remote(repo_path, default_branch)
|
|
906
|
+
# Clean untracked files after checkout
|
|
907
|
+
await self._clean_untracked_files(repo_path)
|
|
879
908
|
elif default_branch == "dev":
|
|
880
909
|
# dev branch doesn't exist, try main
|
|
881
910
|
logger.info("Branch 'dev' not found, trying 'main'",
|
|
@@ -891,6 +920,10 @@ class ContainerPool:
|
|
|
891
920
|
if process.returncode == 0:
|
|
892
921
|
logger.info("Checked out main branch",
|
|
893
922
|
repo=repo_name)
|
|
923
|
+
# Reset to match remote branch so we pick up fetched changes
|
|
924
|
+
await self._reset_to_remote(repo_path, "main")
|
|
925
|
+
# Clean untracked files after checkout
|
|
926
|
+
await self._clean_untracked_files(repo_path)
|
|
894
927
|
else:
|
|
895
928
|
# Both failed, stay on current branch (probably master or main after clone)
|
|
896
929
|
logger.warning("Could not checkout dev or main branch, staying on current branch",
|
|
@@ -903,6 +936,59 @@ class ContainerPool:
|
|
|
903
936
|
branch=default_branch,
|
|
904
937
|
stderr=stderr.decode()[:200] if stderr else "")
|
|
905
938
|
|
|
939
|
+
async def _reset_to_remote(self, repo_path: Path, branch: str) -> None:
|
|
940
|
+
"""Reset local branch to match the remote tracking branch.
|
|
941
|
+
|
|
942
|
+
After 'git fetch --all', the local branch may still be behind origin.
|
|
943
|
+
This ensures the working tree matches the latest fetched remote state.
|
|
944
|
+
|
|
945
|
+
Args:
|
|
946
|
+
repo_path: Path to the repository
|
|
947
|
+
branch: Branch name to reset (e.g. "main")
|
|
948
|
+
"""
|
|
949
|
+
reset_cmd = ["git", "-C", str(repo_path), "reset", "--hard", f"origin/{branch}"]
|
|
950
|
+
process = await asyncio.create_subprocess_exec(
|
|
951
|
+
*reset_cmd,
|
|
952
|
+
stdout=asyncio.subprocess.PIPE,
|
|
953
|
+
stderr=asyncio.subprocess.PIPE
|
|
954
|
+
)
|
|
955
|
+
stdout, stderr = await process.communicate()
|
|
956
|
+
|
|
957
|
+
if process.returncode == 0:
|
|
958
|
+
logger.info("Reset branch to match remote",
|
|
959
|
+
repo_path=str(repo_path),
|
|
960
|
+
branch=branch)
|
|
961
|
+
else:
|
|
962
|
+
logger.warning("Could not reset to remote branch",
|
|
963
|
+
repo_path=str(repo_path),
|
|
964
|
+
branch=branch,
|
|
965
|
+
stderr=stderr.decode()[:200] if stderr else "")
|
|
966
|
+
|
|
967
|
+
async def _clean_untracked_files(self, repo_path: Path) -> None:
|
|
968
|
+
"""Remove untracked files and directories from repository.
|
|
969
|
+
|
|
970
|
+
Important for reusing repocache between tasks to avoid leftover files
|
|
971
|
+
from previous runs affecting the next task.
|
|
972
|
+
|
|
973
|
+
Args:
|
|
974
|
+
repo_path: Path to the repository
|
|
975
|
+
"""
|
|
976
|
+
clean_cmd = ["git", "-C", str(repo_path), "clean", "-fd"]
|
|
977
|
+
process = await asyncio.create_subprocess_exec(
|
|
978
|
+
*clean_cmd,
|
|
979
|
+
stdout=asyncio.subprocess.PIPE,
|
|
980
|
+
stderr=asyncio.subprocess.PIPE
|
|
981
|
+
)
|
|
982
|
+
stdout, stderr = await process.communicate()
|
|
983
|
+
|
|
984
|
+
if process.returncode == 0:
|
|
985
|
+
logger.info("Cleaned untracked files from repository",
|
|
986
|
+
repo_path=str(repo_path))
|
|
987
|
+
else:
|
|
988
|
+
logger.warning("Could not clean untracked files",
|
|
989
|
+
repo_path=str(repo_path),
|
|
990
|
+
stderr=stderr.decode()[:200] if stderr else "")
|
|
991
|
+
|
|
906
992
|
async def _ensure_repository_cloned(
|
|
907
993
|
self,
|
|
908
994
|
repo_name: str,
|
|
@@ -1211,6 +1297,7 @@ Please check the webhook handler logs for more details, or try mentioning me aga
|
|
|
1211
1297
|
"idle_timeout_minutes": self.config.container_timeout_minutes,
|
|
1212
1298
|
"max_age_hours": self.config.container_max_age_hours,
|
|
1213
1299
|
"check_interval_seconds": self.config.cleanup_check_interval_seconds,
|
|
1300
|
+
"stop_container_after_task": self.config.stop_container_after_task,
|
|
1214
1301
|
},
|
|
1215
1302
|
}
|
|
1216
1303
|
|
|
@@ -1367,36 +1454,54 @@ Please check the webhook handler logs for more details, or try mentioning me aga
|
|
|
1367
1454
|
logger.error("Error in idle cleanup worker", error=str(e))
|
|
1368
1455
|
|
|
1369
1456
|
|
|
1370
|
-
async def _cleanup_container(
|
|
1457
|
+
async def _cleanup_container(
|
|
1458
|
+
self,
|
|
1459
|
+
dev_name: str,
|
|
1460
|
+
repo_path: Path,
|
|
1461
|
+
remove_workspace: bool = True,
|
|
1462
|
+
remove_container: bool = True
|
|
1463
|
+
) -> None:
|
|
1371
1464
|
"""Clean up a container after use.
|
|
1372
|
-
|
|
1465
|
+
|
|
1373
1466
|
Args:
|
|
1374
1467
|
dev_name: Name of container to clean up
|
|
1375
1468
|
repo_path: Path to repository on host
|
|
1469
|
+
remove_workspace: If True (default), also remove the workspace.
|
|
1470
|
+
If False, only stop the container but keep the workspace
|
|
1471
|
+
for faster reuse on restart.
|
|
1472
|
+
remove_container: If True (default), remove the container after stopping.
|
|
1473
|
+
If False, only stop the container (it can be restarted quickly).
|
|
1376
1474
|
"""
|
|
1377
1475
|
try:
|
|
1378
1476
|
# Create project and managers for cleanup
|
|
1379
1477
|
project = Project(repo_path)
|
|
1380
|
-
|
|
1478
|
+
|
|
1381
1479
|
# Use the same config as the rest of the webhook handler
|
|
1382
1480
|
workspace_manager = WorkspaceManager(project, self.config)
|
|
1383
1481
|
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
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1482
|
+
|
|
1483
|
+
# Stop container (and optionally remove it)
|
|
1484
|
+
logger.info("Starting container stop", container=dev_name, remove=remove_container)
|
|
1485
|
+
stop_success = container_manager.stop_container(dev_name, remove=remove_container)
|
|
1486
|
+
logger.info("Container stop result", container=dev_name, success=stop_success, removed=remove_container)
|
|
1487
|
+
|
|
1488
|
+
# Remove workspace only if requested
|
|
1489
|
+
workspace_success = True
|
|
1490
|
+
if remove_workspace:
|
|
1491
|
+
logger.info("Starting workspace removal", container=dev_name)
|
|
1492
|
+
workspace_success = workspace_manager.remove_workspace(dev_name)
|
|
1493
|
+
logger.info("Workspace removal result", container=dev_name, success=workspace_success)
|
|
1494
|
+
else:
|
|
1495
|
+
logger.info("Keeping workspace for faster reuse",
|
|
1496
|
+
container=dev_name,
|
|
1497
|
+
workspace_path=str(workspace_manager.get_workspace_dir(dev_name)))
|
|
1498
|
+
|
|
1499
|
+
logger.info("Container cleanup complete",
|
|
1396
1500
|
container=dev_name,
|
|
1397
1501
|
container_stopped=stop_success,
|
|
1398
|
-
|
|
1399
|
-
|
|
1502
|
+
container_removed=remove_container,
|
|
1503
|
+
workspace_removed=workspace_success if remove_workspace else "skipped")
|
|
1504
|
+
|
|
1400
1505
|
except Exception as e:
|
|
1401
1506
|
logger.error("Container cleanup failed",
|
|
1402
1507
|
container=dev_name,
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|