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.
- {devs_webhook-1.2.0 → devs_webhook-2.0.0}/PKG-INFO +1 -1
- {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook/config.py +15 -2
- {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook/core/claude_dispatcher.py +12 -5
- {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook/core/container_pool.py +92 -19
- {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook/github/models.py +1 -0
- {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook/github/parser.py +9 -2
- {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook/main_cli.py +14 -9
- {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook.egg-info/PKG-INFO +1 -1
- {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook.egg-info/SOURCES.txt +2 -0
- {devs_webhook-1.2.0 → devs_webhook-2.0.0}/pyproject.toml +1 -1
- devs_webhook-2.0.0/tests/test_cleanup_mode.py +165 -0
- devs_webhook-2.0.0/tests/test_stop_container_after_task.py +348 -0
- {devs_webhook-1.2.0 → devs_webhook-2.0.0}/tests/test_webhook_parser.py +120 -3
- {devs_webhook-1.2.0 → devs_webhook-2.0.0}/LICENSE +0 -0
- {devs_webhook-1.2.0 → devs_webhook-2.0.0}/README.md +0 -0
- {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook/__init__.py +0 -0
- {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook/app.py +0 -0
- {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook/cli/__init__.py +0 -0
- {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook/cli/worker.py +0 -0
- {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook/core/__init__.py +0 -0
- {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook/core/base_dispatcher.py +0 -0
- {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook/core/deduplication.py +0 -0
- {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook/core/repository_manager.py +0 -0
- {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook/core/task_processor.py +0 -0
- {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook/core/test_dispatcher.py +0 -0
- {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook/core/webhook_config.py +0 -0
- {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook/core/webhook_handler.py +0 -0
- {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook/github/__init__.py +0 -0
- {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook/github/app_auth.py +0 -0
- {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook/github/client.py +0 -0
- {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook/sources/__init__.py +0 -0
- {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook/sources/base.py +0 -0
- {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook/sources/sqs_source.py +0 -0
- {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook/sources/webhook_source.py +0 -0
- {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook/utils/__init__.py +0 -0
- {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook/utils/async_utils.py +0 -0
- {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook/utils/container_logs.py +0 -0
- {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook/utils/github.py +0 -0
- {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook/utils/logging.py +0 -0
- {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook/utils/s3_artifacts.py +0 -0
- {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook/utils/serialization.py +0 -0
- {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook.egg-info/dependency_links.txt +0 -0
- {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook.egg-info/entry_points.txt +0 -0
- {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook.egg-info/requires.txt +0 -0
- {devs_webhook-1.2.0 → devs_webhook-2.0.0}/devs_webhook.egg-info/top_level.txt +0 -0
- {devs_webhook-1.2.0 → devs_webhook-2.0.0}/setup.cfg +0 -0
- {devs_webhook-1.2.0 → devs_webhook-2.0.0}/tests/test_allowlist.py +0 -0
- {devs_webhook-1.2.0 → devs_webhook-2.0.0}/tests/test_authentication.py +0 -0
- {devs_webhook-1.2.0 → devs_webhook-2.0.0}/tests/test_authorized_users.py +0 -0
- {devs_webhook-1.2.0 → devs_webhook-2.0.0}/tests/test_ci_container_pool.py +0 -0
- {devs_webhook-1.2.0 → devs_webhook-2.0.0}/tests/test_container_logs.py +0 -0
- {devs_webhook-1.2.0 → devs_webhook-2.0.0}/tests/test_single_queue.py +0 -0
- {devs_webhook-1.2.0 → devs_webhook-2.0.0}/tests/test_sqs_burst.py +0 -0
|
@@ -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
|
-
#
|
|
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,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(
|
|
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
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
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
|
-
|
|
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,
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
503
|
+
# Find webhook-created containers only
|
|
502
504
|
try:
|
|
503
|
-
containers = docker.find_containers_by_labels({
|
|
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
|
|
514
|
+
click.echo("✅ No webhook containers found")
|
|
510
515
|
return
|
|
511
516
|
|
|
512
|
-
click.echo(f"Found {len(containers)}
|
|
517
|
+
click.echo(f"Found {len(containers)} webhook container(s)")
|
|
513
518
|
|
|
514
519
|
now = datetime.now(tz=timezone.utc)
|
|
515
520
|
cleaned = 0
|
|
@@ -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
|