diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ba39599..97e5a0c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -62,7 +62,7 @@ jobs: python -m pip install --no-deps -e . python -m pip install \ "PyGithub==2.4.0" "PyJWT[crypto]==2.13.0" \ - "aiohttp==3.14.1" "ipython==8.10.0" \ + "aiohttp==3.14.3" "ipython==8.10.0" \ "langchain-core==1.3.3" "langchain-mistralai==1.0.0" \ "langchain-openai==1.1.14" "langchain-text-splitters==1.1.2" \ "nest-asyncio==1.6.0" "numpy==1.26.4" "pydantic==2.10.0" \ @@ -129,7 +129,7 @@ jobs: assert callable(Repository.get_ci_status_with_status) assert callable(Repository.read_text_file_bounded) assert BoundedTextReadResult.__name__ == "BoundedTextReadResult" - assert llama_github.__version__ == "0.4.5" + assert llama_github.__version__ == "0.4.6" PY dependency-audit: diff --git a/CHANGELOG.md b/CHANGELOG.md index b1db5df..a444069 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.4.6] - 2026-08-13 + +### Changed +- Requested GitHub's latest check-run view, retained stable run/app/suite identities, and reconciled obsolete attempts without merging equal check names from distinct suites or apps +- Added page counts and honest truncation/error metadata to bounded status and check-run retrieval while retaining already observed evidence when a later page or CI surface fails + +### Fixed +- Removed the synthetic space after unified diff addition/deletion markers so new-file column-zero content and real indentation remain distinguishable from tool decoration +- Raised the `aiohttp` security floor to `3.14.3` + ## [0.4.5] - 2026-07-20 ### Added diff --git a/README.md b/README.md index 8b03fb5..8167447 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ pip install llama-github Current maintained runtime target: Python `3.10` through `3.14`. -Version `0.4.5` keeps the existing high-level API while allowing retrieval-only +Version `0.4.6` keeps the existing high-level API while allowing retrieval-only integrations to import GitHub and diff helpers without loading the ML/RAG dependency stack. Built-in Jina models are pinned to immutable revisions; custom remote-code models should provide their own immutable revision. Pull-request retrieval now keeps @@ -30,6 +30,9 @@ opt into typed, 2 MiB-capped lockfile or CI-config reads without weakening the generic file policy. Memory-sensitive PR consumers can also opt into per-file and cumulative source limits; oversized source falls back to GitHub's changed-file patch without changing the default retrieval API. +Exact-head CI snapshots expose stable check-run identity, latest-attempt semantics, +bounded page completeness, and retained evidence from partial failures. Synthetic +new-file/deletion diffs use standard unified markers without inserting source spaces. ## Usage diff --git a/llama_github/data_retrieval/github_entities.py b/llama_github/data_retrieval/github_entities.py index 77befa9..cb96689 100755 --- a/llama_github/data_retrieval/github_entities.py +++ b/llama_github/data_retrieval/github_entities.py @@ -410,18 +410,37 @@ def _bounded_github_call( fetch: Callable[[], Any], max_items: int, ) -> RetrievalResult: - """Preserve items already fetched when a paginated PyGithub call fails.""" + """Preserve bounded items and page truth when a GitHub call fails.""" if max_items <= 0: raise ValueError("max_items must be positive") values = [] + pages_fetched = 0 try: - iterator = iter(fetch()) - for _ in range(max_items + 1): - try: - values.append(next(iterator)) - except StopIteration: - break + result = fetch() + get_page = getattr(result, "get_page", None) + if callable(get_page): + page_index = 0 + while len(values) <= max_items: + page = list(get_page(page_index)) + pages_fetched += 1 + if not page: + break + remaining = max_items + 1 - len(values) + values.extend(page[:remaining]) + if len(values) > max_items: + break + page_index += 1 + else: + # Plain lists and legacy iterators have no page boundary to + # expose. Treat the single bounded traversal as one page. + pages_fetched = 1 + iterator = iter(result) + for _ in range(max_items + 1): + try: + values.append(next(iterator)) + except StopIteration: + break except Exception as exc: retained = values[:max_items] return RetrievalResult( @@ -431,7 +450,10 @@ def _bounded_github_call( if retained else RetrievalOutcome.ERROR ), - truncated=bool(retained), + pages_fetched=pages_fetched, + # A transport failure makes the result incomplete, but it + # does not prove the configured item bound was exceeded. + truncated=len(values) > max_items, status_code=getattr(exc, "status", None), error_type=type(exc).__name__, ) @@ -445,9 +467,96 @@ def _bounded_github_call( if truncated else (RetrievalOutcome.OK if retained else RetrievalOutcome.NO_HIT) ), + pages_fetched=pages_fetched, truncated=truncated, ) + @staticmethod + def _ci_scalar(value: Any) -> Any: + """Return only stable JSON scalars from optional PyGithub fields.""" + return ( + value + if isinstance(value, (str, int)) and not isinstance(value, bool) + else None + ) + + def _check_run_payload(self, check_run: Any) -> dict: + """Project a check run with enough identity for honest reconciliation.""" + app = getattr(check_run, "app", None) + check_suite = getattr(check_run, "check_suite", None) + started_at = getattr(check_run, "started_at", None) + completed_at = getattr(check_run, "completed_at", None) + return { + "id": self._ci_scalar(getattr(check_run, "id", None)), + "name": getattr(check_run, "name", None), + "status": getattr(check_run, "status", None), + "conclusion": getattr(check_run, "conclusion", None), + "started_at": self.to_isoformat(started_at) if started_at else None, + "completed_at": ( + self.to_isoformat(completed_at) if completed_at else None + ), + # Keep the historical field bound to the GitHub check-run page. + "details_url": self._ci_scalar(getattr(check_run, "html_url", None)), + "external_details_url": self._ci_scalar( + getattr(check_run, "details_url", None) + ), + "external_id": self._ci_scalar(getattr(check_run, "external_id", None)), + "app_id": self._ci_scalar(getattr(app, "id", None)), + "app_slug": self._ci_scalar(getattr(app, "slug", None)), + "check_suite_id": self._ci_scalar(getattr(check_suite, "id", None)), + } + + @staticmethod + def _check_run_identity(check_run: dict) -> Optional[tuple]: + """Identify attempts within one suite without merging other workflows/apps.""" + suite_id = check_run.get("check_suite_id") + if suite_id is None: + return None + return ( + check_run.get("app_id") or check_run.get("app_slug"), + suite_id, + str(check_run.get("name") or "unknown"), + ) + + @staticmethod + def _check_run_recency(check_run: dict) -> tuple: + """Order attempts so a newer in-progress run supersedes stale completion.""" + def sequence(value: Any) -> int: + try: + return int(value) + except (TypeError, ValueError): + return -1 + + return ( + str(check_run.get("started_at") or check_run.get("completed_at") or ""), + str(check_run.get("completed_at") or ""), + sequence(check_run.get("id")), + ) + + @classmethod + def _current_check_runs(cls, check_runs: list[dict]) -> list[dict]: + """Keep the latest run per app/suite/name and preserve distinct identities.""" + current: dict[tuple, dict] = {} + ordered: list[tuple[Optional[tuple], Optional[dict]]] = [] + for check_run in check_runs: + identity = cls._check_run_identity(check_run) + if identity is None: + # Without a suite identity, collapsing equal names could hide + # distinct workflows. Preserve the evidence instead. + ordered.append((None, check_run)) + continue + existing = current.get(identity) + if existing is None: + ordered.append((identity, None)) + if existing is None or cls._check_run_recency( + check_run + ) >= cls._check_run_recency(existing): + current[identity] = check_run + return [ + check_run if identity is None else current[identity] + for identity, check_run in ordered + ] + @staticmethod def _aggregate_retrieval_outcome( statuses_result: RetrievalResult, @@ -504,7 +613,7 @@ def get_ci_status_with_status( max_statuses, ) check_runs_result = self._bounded_github_call( - head_commit.get_check_runs, + lambda: head_commit.get_check_runs(filter="latest"), max_check_runs, ) statuses = [ @@ -530,25 +639,12 @@ def get_ci_status_with_status( ): current_statuses[context] = status statuses = list(current_statuses.values()) - check_runs = [ - { - "name": check_run.name, - "status": check_run.status, - "conclusion": check_run.conclusion, - "started_at": ( - self.to_isoformat(check_run.started_at) - if check_run.started_at - else None - ), - "completed_at": ( - self.to_isoformat(check_run.completed_at) - if check_run.completed_at - else None - ), - "details_url": check_run.html_url, - } - for check_run in check_runs_result.items - ] + check_runs = self._current_check_runs( + [ + self._check_run_payload(check_run) + for check_run in check_runs_result.items + ] + ) status_states = { str(status.get("state") or "").strip().lower() for status in statuses diff --git a/llama_github/utils.py b/llama_github/utils.py index 8155f47..d838775 100755 --- a/llama_github/utils.py +++ b/llama_github/utils.py @@ -73,10 +73,10 @@ def generate_custom_diff(base_content: str, head_content: str, context_lines: in return "" # Both contents are None, no diff to generate elif base_content is None: # File is newly added - return "".join(f"+ {line}\n" for line in head_content.splitlines()) + return "".join(f"+{line}\n" for line in head_content.splitlines()) elif head_content is None: # File is deleted - return "".join(f"- {line}\n" for line in base_content.splitlines()) + return "".join(f"-{line}\n" for line in base_content.splitlines()) # Use empty strings for None content to ensure difflib handles them correctly # as file additions or deletions. This is more robust and aligns with difflib's expectations. diff --git a/llama_github/version.py b/llama_github/version.py index 98a433b..3dd3d2d 100755 --- a/llama_github/version.py +++ b/llama_github/version.py @@ -1 +1 @@ -__version__ = "0.4.5" +__version__ = "0.4.6" diff --git a/pyproject.toml b/pyproject.toml index 0ceba43..092e0bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ classifiers = [ dependencies = [ "PyGithub>=2.4,<3", "PyJWT[crypto]>=2.13,<3", - "aiohttp>=3.14.1,<4", + "aiohttp>=3.14.3,<4", "ipython>=8.10,<9", "langchain-core>=1.3.3,<2", "langchain-mistralai>=1.0,<2", diff --git a/tests/test_data_retrieval.py b/tests/test_data_retrieval.py index 35ed85e..7317c00 100644 --- a/tests/test_data_retrieval.py +++ b/tests/test_data_retrieval.py @@ -933,6 +933,308 @@ def test_ci_status_helper_keeps_only_latest_state_per_status_context( assert snapshot.statuses_meta["item_count"] == 2 assert snapshot.retrieval_meta["ci_statuses"]["current_item_count"] == 1 + @pytest.mark.parametrize( + ("old_conclusion", "latest_conclusion"), + [("failure", "success"), ("success", "failure")], + ) + def test_ci_status_helper_requests_latest_check_runs_and_reconciles_attempts( + self, + mock_github_instance, + mock_repo_object, + old_conclusion, + latest_conclusion, + ): + mock_github_instance.get_repo.return_value = mock_repo_object + + def check_run( + *, + run_id, + suite_id, + app_id, + app_slug, + name="verify", + status="completed", + conclusion="success", + started_at, + ): + return SimpleNamespace( + id=run_id, + name=name, + status=status, + conclusion=conclusion, + started_at=started_at, + completed_at=started_at, + html_url=f"https://github.com/checks/{run_id}", + details_url=f"https://ci.example.invalid/{run_id}", + external_id=f"external-{run_id}", + app=SimpleNamespace(id=app_id, slug=app_slug), + check_suite=SimpleNamespace(id=suite_id), + ) + + old_failure = check_run( + run_id=10, + suite_id=100, + app_id=1, + app_slug="github-actions", + conclusion=old_conclusion, + started_at=datetime(2026, 1, 1, tzinfo=timezone.utc), + ) + latest_success = check_run( + run_id=11, + suite_id=100, + app_id=1, + app_slug="github-actions", + conclusion=latest_conclusion, + started_at=datetime(2026, 1, 2, tzinfo=timezone.utc), + ) + same_name_distinct_workflow = check_run( + run_id=20, + suite_id=200, + app_id=1, + app_slug="github-actions", + conclusion="failure", + started_at=datetime(2026, 1, 2, tzinfo=timezone.utc), + ) + same_name_distinct_app = check_run( + run_id=30, + suite_id=300, + app_id=2, + app_slug="third-party-ci", + conclusion="neutral", + started_at=datetime(2026, 1, 2, tzinfo=timezone.utc), + ) + head_commit = MagicMock() + head_commit.get_statuses.return_value = [] + head_commit.get_check_runs.return_value = [ + old_failure, + latest_success, + same_name_distinct_workflow, + same_name_distinct_app, + ] + mock_repo_object.get_commit.return_value = head_commit + repo = Repository("owner/test-repo", mock_github_instance) + + snapshot = repo.get_ci_status_with_status("head-sha") + + head_commit.get_check_runs.assert_called_once_with(filter="latest") + assert [item["id"] for item in snapshot.check_runs] == [11, 20, 30] + assert snapshot.check_runs[0] == { + "id": 11, + "name": "verify", + "status": "completed", + "conclusion": latest_conclusion, + "started_at": "2026-01-02T00:00:00Z", + "completed_at": "2026-01-02T00:00:00Z", + "details_url": "https://github.com/checks/11", + "external_details_url": "https://ci.example.invalid/11", + "external_id": "external-11", + "app_id": 1, + "app_slug": "github-actions", + "check_suite_id": 100, + } + assert snapshot.check_runs_meta["item_count"] == 4 + assert snapshot.retrieval_meta["ci_check_runs"]["current_item_count"] == 3 + + @pytest.mark.parametrize( + ("status", "conclusion"), + [ + ("in_progress", None), + ("completed", "failure"), + ("completed", "cancelled"), + ("completed", "skipped"), + ("completed", "neutral"), + ("completed", "action_required"), + ("completed", "success"), + ], + ) + def test_ci_status_helper_preserves_check_run_states( + self, mock_github_instance, mock_repo_object, status, conclusion + ): + mock_github_instance.get_repo.return_value = mock_repo_object + run = SimpleNamespace( + id=1, + name="policy", + status=status, + conclusion=conclusion, + started_at=datetime(2026, 1, 1, tzinfo=timezone.utc), + completed_at=None, + html_url="https://github.com/checks/1", + details_url="https://ci.example.invalid/1", + external_id="external-1", + app=SimpleNamespace(id=1, slug="policy-app"), + check_suite=SimpleNamespace(id=1), + ) + head_commit = MagicMock() + head_commit.get_statuses.return_value = [] + head_commit.get_check_runs.return_value = [run] + mock_repo_object.get_commit.return_value = head_commit + repo = Repository("owner/test-repo", mock_github_instance) + + snapshot = repo.get_ci_status_with_status("head-sha") + + assert snapshot.check_runs[0]["status"] == status + assert snapshot.check_runs[0]["conclusion"] == conclusion + + def test_ci_status_helper_records_multi_page_bound_and_truncation( + self, mock_github_instance, mock_repo_object + ): + mock_github_instance.get_repo.return_value = mock_repo_object + + class PagedRuns: + def __init__(self, values, page_size=30): + self.values = values + self.page_size = page_size + + def get_page(self, page_index): + start = page_index * self.page_size + return self.values[start : start + self.page_size] + + def __iter__(self): + return iter(self.values) + + runs = [] + for index in range(101): + runs.append( + SimpleNamespace( + id=index, + name=f"check-{index}", + status="completed", + conclusion="failure" if index == 0 else "success", + started_at=datetime(2026, 1, 1, tzinfo=timezone.utc), + completed_at=datetime(2026, 1, 1, tzinfo=timezone.utc), + html_url=f"https://github.com/checks/{index}", + details_url=None, + external_id=None, + app=SimpleNamespace(id=1, slug="github-actions"), + check_suite=SimpleNamespace(id=index), + ) + ) + head_commit = MagicMock() + head_commit.get_statuses.return_value = [] + head_commit.get_check_runs.return_value = PagedRuns(runs) + mock_repo_object.get_commit.return_value = head_commit + repo = Repository("owner/test-repo", mock_github_instance) + + snapshot = repo.get_ci_status_with_status("head-sha", max_check_runs=100) + + assert len(snapshot.check_runs) == 100 + assert snapshot.check_runs[0]["conclusion"] == "failure" + assert snapshot.outcome is RetrievalOutcome.PARTIAL + assert snapshot.check_runs_meta == { + "outcome": "partial", + "item_count": 100, + "pages_fetched": 4, + "truncated": True, + "status_code": None, + "error_type": None, + } + + def test_ci_status_helper_retains_first_pages_when_later_page_fails( + self, mock_github_instance, mock_repo_object + ): + mock_github_instance.get_repo.return_value = mock_repo_object + failed_run = SimpleNamespace( + id=1, + name="verify", + status="completed", + conclusion="failure", + started_at=datetime(2026, 1, 1, tzinfo=timezone.utc), + completed_at=datetime(2026, 1, 1, tzinfo=timezone.utc), + html_url="https://github.com/checks/1", + details_url=None, + external_id=None, + app=SimpleNamespace(id=1, slug="github-actions"), + check_suite=SimpleNamespace(id=1), + ) + + class FailingSecondPage: + def get_page(self, page_index): + if page_index == 0: + return [failed_run] + raise GithubException(503, {"message": "page unavailable"}) + + def __iter__(self): + yield failed_run + raise GithubException(503, {"message": "page unavailable"}) + + head_commit = MagicMock() + head_commit.get_statuses.return_value = [] + head_commit.get_check_runs.return_value = FailingSecondPage() + mock_repo_object.get_commit.return_value = head_commit + repo = Repository("owner/test-repo", mock_github_instance) + + snapshot = repo.get_ci_status_with_status("head-sha") + + assert snapshot.check_runs[0]["conclusion"] == "failure" + assert snapshot.outcome is RetrievalOutcome.PARTIAL + assert snapshot.check_runs_meta == { + "outcome": "partial", + "item_count": 1, + "pages_fetched": 1, + "truncated": False, + "status_code": 503, + "error_type": "GithubException", + } + + def test_ci_status_helper_retains_statuses_when_checks_permission_is_denied( + self, mock_github_instance, mock_repo_object + ): + mock_github_instance.get_repo.return_value = mock_repo_object + failed_status = SimpleNamespace( + context="verify", + state="failure", + description="dependency mirror unavailable", + target_url="https://ci.example.invalid/status/1", + created_at=datetime(2026, 1, 1, tzinfo=timezone.utc), + updated_at=datetime(2026, 1, 1, tzinfo=timezone.utc), + ) + head_commit = MagicMock() + head_commit.get_statuses.return_value = [failed_status] + head_commit.get_check_runs.side_effect = GithubException( + 403, + {"message": "Resource not accessible by integration"}, + ) + mock_repo_object.get_commit.return_value = head_commit + repo = Repository("owner/test-repo", mock_github_instance) + + snapshot = repo.get_ci_status_with_status("head-sha") + + assert snapshot.statuses[0]["state"] == "failure" + assert snapshot.check_runs == [] + assert snapshot.outcome is RetrievalOutcome.PARTIAL + assert snapshot.statuses_meta["outcome"] == "ok" + assert snapshot.check_runs_meta["outcome"] == "error" + assert snapshot.check_runs_meta["status_code"] == 403 + + def test_ci_status_helper_marks_more_than_one_hundred_statuses_partial( + self, mock_github_instance, mock_repo_object + ): + mock_github_instance.get_repo.return_value = mock_repo_object + statuses = [ + SimpleNamespace( + context=f"status-{index}", + state="failure" if index == 0 else "success", + description="quota exceeded" if index == 0 else "passed", + target_url=f"https://ci.example.invalid/status/{index}", + created_at=datetime(2026, 1, 1, tzinfo=timezone.utc), + updated_at=datetime(2026, 1, 1, tzinfo=timezone.utc), + ) + for index in range(101) + ] + head_commit = MagicMock() + head_commit.get_statuses.return_value = statuses + head_commit.get_check_runs.return_value = [] + mock_repo_object.get_commit.return_value = head_commit + repo = Repository("owner/test-repo", mock_github_instance) + + snapshot = repo.get_ci_status_with_status("head-sha", max_statuses=100) + + assert len(snapshot.statuses) == 100 + assert snapshot.statuses[0]["state"] == "failure" + assert snapshot.outcome is RetrievalOutcome.PARTIAL + assert snapshot.statuses_meta["truncated"] is True + assert snapshot.statuses_meta["pages_fetched"] == 1 + def test_related_issue_collection_makes_no_calls_for_zero_references( self, mock_github_instance, mock_repo_object ): diff --git a/tests/test_distribution_contents.py b/tests/test_distribution_contents.py index 4391805..b7b9182 100644 --- a/tests/test_distribution_contents.py +++ b/tests/test_distribution_contents.py @@ -16,20 +16,20 @@ def _write_wheel(path: Path, *, extra_files=()) -> None: with zipfile.ZipFile(path, "w") as archive: for name in sorted(expected_package_files(ROOT)): archive.writestr(name, b"") - archive.writestr("llama_github-0.4.5.dist-info/METADATA", b"") + archive.writestr("llama_github-0.4.6.dist-info/METADATA", b"") for name in extra_files: archive.writestr(name, b"") def test_wheel_content_verifier_accepts_exact_source_package(tmp_path): - wheel = tmp_path / "llama_github-0.4.5-py3-none-any.whl" + wheel = tmp_path / "llama_github-0.4.6-py3-none-any.whl" _write_wheel(wheel) verify_wheel(wheel, source_root=ROOT) def test_wheel_content_verifier_rejects_stale_duplicate_module(tmp_path): - wheel = tmp_path / "llama_github-0.4.5-py3-none-any.whl" + wheel = tmp_path / "llama_github-0.4.6-py3-none-any.whl" _write_wheel( wheel, extra_files=("llama_github/data_retrieval/github_entities 2.py",), diff --git a/tests/test_minimal_imports.py b/tests/test_minimal_imports.py index d172c0b..4241d6e 100644 --- a/tests/test_minimal_imports.py +++ b/tests/test_minimal_imports.py @@ -22,7 +22,7 @@ def test_retrieval_imports_do_not_load_heavy_rag_dependencies(): assert callable(Repository.get_ci_status_with_status) assert callable(Repository.read_text_file_bounded) assert BoundedTextReadResult.__name__ == "BoundedTextReadResult" - assert llama_github.__version__ == "0.4.5" + assert llama_github.__version__ == "0.4.6" """ ) diff --git a/tests/test_utils.py b/tests/test_utils.py index 8994401..565f5f8 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -13,13 +13,34 @@ def test_generate_custom_diff_new_file(self): base = None head = "new line" diff = DiffGenerator.generate_custom_diff(base, head, context_lines=1) - assert "+ new line" in diff + assert diff == "+new line\n" + + def test_generate_custom_diff_new_file_preserves_column_zero_and_indentation(self): + diff = DiffGenerator.generate_custom_diff( + None, + "---\nlayout: post\n indented: true\n", + context_lines=1, + ) + + assert diff == "+---\n+layout: post\n+ indented: true\n" def test_generate_custom_diff_deleted_file(self): base = "old line" head = None diff = DiffGenerator.generate_custom_diff(base, head, context_lines=1) - assert "- old line" in diff + assert diff == "-old line\n" + + def test_generate_custom_diff_modified_file_keeps_standard_context_markers(self): + diff = DiffGenerator.generate_custom_diff( + "before\nold\nafter", + "before\n new\nafter", + context_lines=1, + ) + + assert " before" in diff + assert "-old" in diff + assert "+ new" in diff + assert " after" in diff def test_find_context_python(self): lines = [