|
| 1 | +from github.GithubObject import NotSet |
1 | 2 | import pytest |
2 | 3 | from unittest.mock import MagicMock, patch |
3 | 4 | from datetime import datetime, timedelta |
|
7 | 8 | ConcourseGithubIssuesVersion, |
8 | 9 | ISO_8601_FORMAT, |
9 | 10 | ) |
| 11 | +from concoursetools import BuildMetadata # Import the actual class |
10 | 12 | from concoursetools.testing import SimpleTestResourceWrapper |
11 | 13 | from github.Issue import Issue |
12 | 14 |
|
13 | 15 |
|
| 16 | +# Helper function to create mock BuildMetadata objects |
| 17 | +def mock_build_metadata(**kwargs) -> BuildMetadata: |
| 18 | + """Creates a BuildMetadata object with default values, allowing overrides.""" |
| 19 | + defaults = { |
| 20 | + "BUILD_ID": "12345", |
| 21 | + "BUILD_NAME": "42", |
| 22 | + "BUILD_JOB_NAME": "test-job", |
| 23 | + "BUILD_PIPELINE_NAME": "test-pipeline", |
| 24 | + "BUILD_PIPELINE_INSTANCE_VARS": '{"var": "value"}', |
| 25 | + "BUILD_TEAM_NAME": "main", |
| 26 | + "ATC_EXTERNAL_URL": "http://concourse.example.com", |
| 27 | + } |
| 28 | + # Map simplified kwargs to the expected BuildMetadata keys |
| 29 | + key_map = { |
| 30 | + "pipeline_name": "BUILD_PIPELINE_NAME", |
| 31 | + "job_name": "BUILD_JOB_NAME", |
| 32 | + "build_name": "BUILD_NAME", |
| 33 | + # Add other mappings if needed |
| 34 | + } |
| 35 | + mapped_kwargs = {key_map.get(k, k): v for k, v in kwargs.items()} |
| 36 | + |
| 37 | + # Override defaults with provided mapped kwargs |
| 38 | + defaults.update(mapped_kwargs) |
| 39 | + # Create BuildMetadata instance using the combined dict |
| 40 | + return BuildMetadata(**defaults) |
| 41 | + |
| 42 | + |
14 | 43 | # Helper function to create mock Issue objects |
15 | 44 | def create_mock_issue( |
16 | 45 | number: int, |
@@ -90,6 +119,9 @@ def mock_github(): |
90 | 119 | with patch("concourse.Github") as MockGithub: |
91 | 120 | mock_gh_instance = MockGithub.return_value |
92 | 121 | mock_repo = MagicMock() |
| 122 | + mock_repo.full_name = ( |
| 123 | + "test/repo" |
| 124 | + ) # Set the full_name attribute for search queries |
93 | 125 | mock_gh_instance.get_repo.return_value = mock_repo |
94 | 126 | # Set a default rate limit mock to avoid errors |
95 | 127 | mock_rate_limit = MagicMock() |
@@ -132,7 +164,7 @@ def test_fetch_new_versions_no_previous( |
132 | 164 | assert version_numbers == expected_issue_numbers |
133 | 165 | # Verify get_issues was called with the correct state and no 'since' |
134 | 166 | mock_repo.get_issues.assert_called_once_with( |
135 | | - state=config_state, labels=[], since=None |
| 167 | + state=config_state, labels=[], since=NotSet |
136 | 168 | ) |
137 | 169 |
|
138 | 170 |
|
@@ -318,8 +350,169 @@ def test_fetch_new_versions_limit_old(mock_github): |
318 | 350 | # get_matching_issues sorts by number ascending: 1, 5, 6, 7, 8, 9 |
319 | 351 | # limit_old_versions=2 takes the first 2: 1, 5 |
320 | 352 | assert version_numbers == {1, 5} |
321 | | - mock_repo.get_issues.assert_called_once_with(state="closed", labels=[], since=None) |
| 353 | + mock_repo.get_issues.assert_called_once_with( |
| 354 | + state="closed", labels=[], since=NotSet |
| 355 | + ) |
| 356 | + |
| 357 | + |
| 358 | +@patch("pathlib.Path.open") |
| 359 | +def test_download_version_tombstones(mock_open, mock_github, tmp_path): |
| 360 | + """Test that download_version tombstones the issue and writes the file.""" |
| 361 | + mock_gh_instance, mock_repo = mock_github |
| 362 | + mock_issue = create_mock_issue( |
| 363 | + number=5, |
| 364 | + title="[bot] Ready Issue", |
| 365 | + state="closed", |
| 366 | + created_at=T_MINUS_2, |
| 367 | + closed_at=T_MINUS_1, |
| 368 | + ) |
| 369 | + mock_repo.get_issue.return_value = mock_issue |
| 370 | + |
| 371 | + resource = ConcourseGithubIssuesResource( |
| 372 | + repository="test/repo", access_token="dummy_token", issue_state="closed" |
| 373 | + ) |
| 374 | + # wrapper = SimpleTestResourceWrapper(resource) # Wrapper not needed for download test |
| 375 | + |
| 376 | + version_to_download = ConcourseGithubIssuesVersion( |
| 377 | + issue_number=5, |
| 378 | + issue_title="[bot] Ready Issue", |
| 379 | + issue_state="closed", |
| 380 | + issue_created_at=T_MINUS_2.strftime(ISO_8601_FORMAT), |
| 381 | + issue_closed_at=T_MINUS_1.strftime(ISO_8601_FORMAT), |
| 382 | + issue_url="http://example.com/issue/5", |
| 383 | + ) |
| 384 | + |
| 385 | + build_meta = mock_build_metadata() # Use default build meta here |
| 386 | + dest_dir = str(tmp_path) |
| 387 | + |
| 388 | + # Call download_version directly on the resource instance |
| 389 | + returned_version, returned_metadata = resource.download_version( |
| 390 | + version=version_to_download, |
| 391 | + destination_dir=dest_dir, |
| 392 | + build_metadata=build_meta, |
| 393 | + ) |
| 394 | + |
| 395 | + # Check tombstoning |
| 396 | + # Need to update the expected title based on the default build_meta name '42' |
| 397 | + mock_repo.get_issue.assert_called_once_with(5) |
| 398 | + # Calculate the expected title exactly how the resource does it |
| 399 | + current_title_from_build = resource.get_title_from_build(build_meta) |
| 400 | + expected_tombstone_title = ( |
| 401 | + f"[CONSUMED #{build_meta.BUILD_NAME}]" + current_title_from_build |
| 402 | + ) |
| 403 | + mock_issue.edit.assert_called_once_with(title=expected_tombstone_title) |
| 404 | + # Check file writing |
| 405 | + # expected_file_path = Path(dest_dir) / "gh_issue.json" # This path wasn't used, just verify open call |
| 406 | + mock_open.assert_called_once_with("w") |
| 407 | + # Check that the file handle's write method was called (actual content check is tricky with mock_open) |
| 408 | + mock_open.return_value.__enter__.return_value.write.assert_called_once() |
| 409 | + |
| 410 | + # Check return values |
| 411 | + assert returned_version == version_to_download |
| 412 | + assert returned_metadata == {} |
| 413 | + |
| 414 | + |
| 415 | +def test_publish_new_version_creates_new_issue(mock_github): |
| 416 | + """Test publish creates a new issue when none exists.""" |
| 417 | + mock_gh_instance, mock_repo = mock_github |
| 418 | + mock_gh_instance.search_issues.return_value = [] # No existing issue found |
| 419 | + created_mock_issue = create_mock_issue( |
| 420 | + number=10, |
| 421 | + title="[bot] Pipeline my-pipeline task my-job completed", |
| 422 | + state="open", |
| 423 | + created_at=NOW, |
| 424 | + ) |
| 425 | + mock_repo.create_issue.return_value = created_mock_issue |
| 426 | + |
| 427 | + resource = ConcourseGithubIssuesResource( |
| 428 | + repository="test/repo", |
| 429 | + access_token="dummy_token", |
| 430 | + issue_state="open", # Important for publish logic |
| 431 | + issue_title_template="[bot] Pipeline {BUILD_PIPELINE_NAME} task {BUILD_JOB_NAME} completed", |
| 432 | + issue_body_template="Build {BUILD_NAME} finished.", |
| 433 | + assignees=["user1"], |
| 434 | + labels=["bot-created"], |
| 435 | + ) |
| 436 | + # wrapper = SimpleTestResourceWrapper(resource) # Wrapper not needed for publish tests |
| 437 | + build_meta = mock_build_metadata( |
| 438 | + pipeline_name="my-pipeline", job_name="my-job", build_name="b123" |
| 439 | + ) |
| 440 | + |
| 441 | + # Use resource directly for publish, wrapper doesn't have it |
| 442 | + version, metadata = resource.publish_new_version( |
| 443 | + sources_dir="dummy", |
| 444 | + build_metadata=build_meta, |
| 445 | + assignees=["user1"], # Pass explicitly if needed by method |
| 446 | + labels=["bot-created"], |
| 447 | + ) |
| 448 | + |
| 449 | + # Check search was called |
| 450 | + expected_title = "[bot] Pipeline my-pipeline task my-job completed" |
| 451 | + expected_query = f'repo:test/repo state:open "{expected_title}" in:title is:issue' |
| 452 | + mock_gh_instance.search_issues.assert_called_once_with(expected_query) |
| 453 | + |
| 454 | + # Check create_issue was called |
| 455 | + expected_body = "Build b123 finished." |
| 456 | + mock_repo.create_issue.assert_called_once_with( |
| 457 | + title=expected_title, |
| 458 | + assignees=["user1"], |
| 459 | + labels=["bot-created"], |
| 460 | + body=expected_body, |
| 461 | + ) |
| 462 | + |
| 463 | + # Check returned version |
| 464 | + assert version.issue_number == 10 |
| 465 | + assert version.issue_title == expected_title |
| 466 | + assert version.issue_state == "open" |
| 467 | + assert metadata == {} |
| 468 | + |
| 469 | + |
| 470 | +def test_publish_new_version_comments_on_existing(mock_github): |
| 471 | + """Test publish comments on an existing issue if found.""" |
| 472 | + mock_gh_instance, mock_repo = mock_github |
| 473 | + existing_mock_issue = create_mock_issue( |
| 474 | + number=9, |
| 475 | + title="[bot] Pipeline my-pipeline task my-job completed", |
| 476 | + state="open", |
| 477 | + created_at=T_MINUS_1, |
| 478 | + ) |
| 479 | + # Mock the create_comment method on the existing issue |
| 480 | + existing_mock_issue.create_comment = MagicMock() |
| 481 | + mock_gh_instance.search_issues.return_value = [ |
| 482 | + existing_mock_issue |
| 483 | + ] # Found existing |
| 484 | + |
| 485 | + resource = ConcourseGithubIssuesResource( |
| 486 | + repository="test/repo", |
| 487 | + access_token="dummy_token", |
| 488 | + issue_state="open", |
| 489 | + issue_title_template="[bot] Pipeline {BUILD_PIPELINE_NAME} task {BUILD_JOB_NAME} completed", |
| 490 | + issue_body_template="Build {BUILD_NAME} finished.", |
| 491 | + ) |
| 492 | + # wrapper = SimpleTestResourceWrapper(resource) # Wrapper not needed for publish tests |
| 493 | + build_meta = mock_build_metadata( |
| 494 | + pipeline_name="my-pipeline", job_name="my-job", build_name="b456" |
| 495 | + ) |
| 496 | + |
| 497 | + # Use resource directly for publish |
| 498 | + version, metadata = resource.publish_new_version( |
| 499 | + sources_dir="dummy", build_metadata=build_meta |
| 500 | + ) |
| 501 | + |
| 502 | + # Check search was called |
| 503 | + expected_title = "[bot] Pipeline my-pipeline task my-job completed" |
| 504 | + expected_query = f'repo:test/repo state:open "{expected_title}" in:title is:issue' |
| 505 | + mock_gh_instance.search_issues.assert_called_once_with(expected_query) |
| 506 | + |
| 507 | + # Check create_issue was NOT called |
| 508 | + mock_repo.create_issue.assert_not_called() |
322 | 509 |
|
| 510 | + # Check create_comment was called on the existing issue |
| 511 | + expected_comment_body = "Build b456 finished." |
| 512 | + existing_mock_issue.create_comment.assert_called_once_with(expected_comment_body) |
323 | 513 |
|
324 | | -# TODO: Add tests for download_version (tombstoning) and publish_new_version (creation/commenting) |
325 | | -# These would require mocking issue.edit(), issue.create_comment(), repo.create_issue(), gh.search_issues() etc. |
| 514 | + # Check returned version matches the existing issue |
| 515 | + assert version.issue_number == 9 |
| 516 | + assert version.issue_title == expected_title |
| 517 | + assert version.issue_state == "open" |
| 518 | + assert metadata == {} |
0 commit comments