llama-github 0.4.1__tar.gz → 0.4.3__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.
- {llama_github-0.4.1 → llama_github-0.4.3}/CHANGELOG.md +19 -0
- {llama_github-0.4.1/llama_github.egg-info → llama_github-0.4.3}/PKG-INFO +5 -4
- {llama_github-0.4.1 → llama_github-0.4.3}/README.md +3 -2
- llama_github-0.4.3/llama_github/data_retrieval/__init__.py +19 -0
- {llama_github-0.4.1 → llama_github-0.4.3}/llama_github/data_retrieval/github_entities.py +356 -1
- {llama_github-0.4.1 → llama_github-0.4.3}/llama_github/github_integration/github_auth_manager.py +98 -0
- llama_github-0.4.3/llama_github/version.py +1 -0
- {llama_github-0.4.1 → llama_github-0.4.3/llama_github.egg-info}/PKG-INFO +5 -4
- {llama_github-0.4.1 → llama_github-0.4.3}/llama_github.egg-info/requires.txt +1 -1
- {llama_github-0.4.1 → llama_github-0.4.3}/pyproject.toml +2 -2
- {llama_github-0.4.1 → llama_github-0.4.3}/tests/test_data_retrieval.py +198 -1
- {llama_github-0.4.1 → llama_github-0.4.3}/tests/test_distribution_contents.py +3 -3
- {llama_github-0.4.1 → llama_github-0.4.3}/tests/test_github_auth_manager.py +61 -0
- {llama_github-0.4.1 → llama_github-0.4.3}/tests/test_minimal_imports.py +4 -2
- llama_github-0.4.1/llama_github/rag_processing/__init__.py +0 -0
- llama_github-0.4.1/llama_github/version.py +0 -1
- {llama_github-0.4.1 → llama_github-0.4.3}/LICENSE +0 -0
- {llama_github-0.4.1 → llama_github-0.4.3}/MANIFEST.in +0 -0
- {llama_github-0.4.1 → llama_github-0.4.3}/llama_github/__init__.py +0 -0
- {llama_github-0.4.1 → llama_github-0.4.3}/llama_github/config/__init__.py +0 -0
- {llama_github-0.4.1 → llama_github-0.4.3}/llama_github/config/config.json +0 -0
- {llama_github-0.4.1 → llama_github-0.4.3}/llama_github/config/config.py +0 -0
- {llama_github-0.4.1 → llama_github-0.4.3}/llama_github/data_retrieval/github_api.py +0 -0
- {llama_github-0.4.1 → llama_github-0.4.3}/llama_github/github_integration/__init__.py +0 -0
- {llama_github-0.4.1 → llama_github-0.4.3}/llama_github/github_rag.py +0 -0
- {llama_github-0.4.1/llama_github/data_retrieval → llama_github-0.4.3/llama_github/llm_integration}/__init__.py +0 -0
- {llama_github-0.4.1 → llama_github-0.4.3}/llama_github/llm_integration/initial_load.py +0 -0
- {llama_github-0.4.1 → llama_github-0.4.3}/llama_github/llm_integration/llm_handler.py +0 -0
- {llama_github-0.4.1 → llama_github-0.4.3}/llama_github/logger.py +0 -0
- {llama_github-0.4.1/llama_github/llm_integration → llama_github-0.4.3/llama_github/rag_processing}/__init__.py +0 -0
- {llama_github-0.4.1 → llama_github-0.4.3}/llama_github/rag_processing/rag_processor.py +0 -0
- {llama_github-0.4.1 → llama_github-0.4.3}/llama_github/utils.py +0 -0
- {llama_github-0.4.1 → llama_github-0.4.3}/llama_github.egg-info/SOURCES.txt +0 -0
- {llama_github-0.4.1 → llama_github-0.4.3}/llama_github.egg-info/dependency_links.txt +0 -0
- {llama_github-0.4.1 → llama_github-0.4.3}/llama_github.egg-info/top_level.txt +0 -0
- {llama_github-0.4.1 → llama_github-0.4.3}/pytest.ini +0 -0
- {llama_github-0.4.1 → llama_github-0.4.3}/scripts/__init__.py +0 -0
- {llama_github-0.4.1 → llama_github-0.4.3}/scripts/verify_distribution_contents.py +0 -0
- {llama_github-0.4.1 → llama_github-0.4.3}/setup.cfg +0 -0
- {llama_github-0.4.1 → llama_github-0.4.3}/setup.py +0 -0
- {llama_github-0.4.1 → llama_github-0.4.3}/tests/__init__.py +0 -0
- {llama_github-0.4.1 → llama_github-0.4.3}/tests/conftest.py +0 -0
- {llama_github-0.4.1 → llama_github-0.4.3}/tests/test_github_rag.py +0 -0
- {llama_github-0.4.1 → llama_github-0.4.3}/tests/test_initial_load.py +0 -0
- {llama_github-0.4.1 → llama_github-0.4.3}/tests/test_llm_handler.py +0 -0
- {llama_github-0.4.1 → llama_github-0.4.3}/tests/test_logger.py +0 -0
- {llama_github-0.4.1 → llama_github-0.4.3}/tests/test_logging_privacy.py +0 -0
- {llama_github-0.4.1 → llama_github-0.4.3}/tests/test_rag_processor.py +0 -0
- {llama_github-0.4.1 → llama_github-0.4.3}/tests/test_utils.py +0 -0
|
@@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.4.3] - 2026-07-15
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- Aligned the `dependency_lock` opt-in with the complete standard lockfile family used by bounded review planning, including `go.sum`, `gradle.lockfile`, `packages.lock.json`, and ecosystem peers
|
|
12
|
+
- Aligned the `ci_config` opt-in with the existing bounded CI path family, including AppVeyor configuration
|
|
13
|
+
|
|
14
|
+
## [0.4.2] - 2026-07-15
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
- Added a typed, fixed-cap repository text-read API with distinct success, policy, missing, oversize, binary, directory, and transport outcomes
|
|
18
|
+
- Added explicit `dependency_lock` and `ci_config` opt-ins without changing the legacy `get_file_content()` exclusion or cache contract
|
|
19
|
+
|
|
20
|
+
### Security
|
|
21
|
+
- Enforced the 2 MiB product cap both from GitHub size metadata and while streaming raw bytes
|
|
22
|
+
- Kept binary, generated, minified, and sensitive paths outside the new opt-in surface
|
|
23
|
+
- Raised the tested `setuptools` and `transformers` lower bounds to versions that clear the current dependency-audit security gate
|
|
24
|
+
|
|
8
25
|
## [0.4.1] - 2026-07-12
|
|
9
26
|
|
|
10
27
|
### Added
|
|
@@ -181,6 +198,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
181
198
|
- Integration with LLM for processing and generating responses
|
|
182
199
|
|
|
183
200
|
[0.1.4]: https://github.com/JetXu-LLM/llama-github/compare/v0.1.3...v0.1.4
|
|
201
|
+
[0.4.3]: https://github.com/JetXu-LLM/llama-github/compare/v0.4.2...v0.4.3
|
|
202
|
+
[0.4.2]: https://github.com/JetXu-LLM/llama-github/compare/v0.4.1...v0.4.2
|
|
184
203
|
[0.4.1]: https://github.com/JetXu-LLM/llama-github/compare/v0.4.0...v0.4.1
|
|
185
204
|
[0.1.3]: https://github.com/JetXu-LLM/llama-github/compare/v0.1.2...v0.1.3
|
|
186
205
|
[0.1.2]: https://github.com/JetXu-LLM/llama-github/compare/v0.1.1...v0.1.2
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: llama-github
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.3
|
|
4
4
|
Summary: GitHub retrieval and context generation utilities for coding assistants and AI developer workflows.
|
|
5
5
|
Author-email: Jet Xu <Voldemort.xu@foxmail.com>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -31,7 +31,7 @@ Requires-Dist: pydantic<3,>=2.10
|
|
|
31
31
|
Requires-Dist: python-dateutil<3,>=2.9
|
|
32
32
|
Requires-Dist: requests<3,>=2.33
|
|
33
33
|
Requires-Dist: tokenizers<1,>=0.22.1
|
|
34
|
-
Requires-Dist: transformers<6,>=5.
|
|
34
|
+
Requires-Dist: transformers<6,>=5.5
|
|
35
35
|
Requires-Dist: urllib3<3,>=2.7
|
|
36
36
|
Provides-Extra: dev
|
|
37
37
|
Requires-Dist: build<2,>=1.2; extra == "dev"
|
|
@@ -61,13 +61,14 @@ pip install llama-github
|
|
|
61
61
|
|
|
62
62
|
Current maintained runtime target: Python `3.10` through `3.14`.
|
|
63
63
|
|
|
64
|
-
Version `0.4.
|
|
64
|
+
Version `0.4.3` keeps the existing high-level API while allowing retrieval-only
|
|
65
65
|
integrations to import GitHub and diff helpers without loading the ML/RAG dependency
|
|
66
66
|
stack. Built-in Jina models are pinned to immutable revisions; custom remote-code
|
|
67
67
|
models should provide their own immutable revision. Pull-request retrieval now keeps
|
|
68
68
|
bounded fetch-health metadata, expands related issues only from PR conversation
|
|
69
69
|
evidence, and preserves review summaries plus each inline comment without collapsing
|
|
70
|
-
them together.
|
|
70
|
+
them together. Deterministic planners can explicitly opt into typed, 2 MiB-capped
|
|
71
|
+
lockfile or CI-config reads without weakening the generic file policy.
|
|
71
72
|
|
|
72
73
|
## Usage
|
|
73
74
|
|
|
@@ -18,13 +18,14 @@ pip install llama-github
|
|
|
18
18
|
|
|
19
19
|
Current maintained runtime target: Python `3.10` through `3.14`.
|
|
20
20
|
|
|
21
|
-
Version `0.4.
|
|
21
|
+
Version `0.4.3` keeps the existing high-level API while allowing retrieval-only
|
|
22
22
|
integrations to import GitHub and diff helpers without loading the ML/RAG dependency
|
|
23
23
|
stack. Built-in Jina models are pinned to immutable revisions; custom remote-code
|
|
24
24
|
models should provide their own immutable revision. Pull-request retrieval now keeps
|
|
25
25
|
bounded fetch-health metadata, expands related issues only from PR conversation
|
|
26
26
|
evidence, and preserves review summaries plus each inline comment without collapsing
|
|
27
|
-
them together.
|
|
27
|
+
them together. Deterministic planners can explicitly opt into typed, 2 MiB-capped
|
|
28
|
+
lockfile or CI-config reads without weakening the generic file policy.
|
|
28
29
|
|
|
29
30
|
## Usage
|
|
30
31
|
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Typed retrieval primitives exposed by llama-github."""
|
|
2
|
+
|
|
3
|
+
from .github_entities import (
|
|
4
|
+
BoundedTextReadOptIn,
|
|
5
|
+
BoundedTextReadOutcome,
|
|
6
|
+
BoundedTextReadResult,
|
|
7
|
+
CIStatusSnapshot,
|
|
8
|
+
Repository,
|
|
9
|
+
RepositoryPool,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"BoundedTextReadOptIn",
|
|
14
|
+
"BoundedTextReadOutcome",
|
|
15
|
+
"BoundedTextReadResult",
|
|
16
|
+
"CIStatusSnapshot",
|
|
17
|
+
"Repository",
|
|
18
|
+
"RepositoryPool",
|
|
19
|
+
]
|
|
@@ -2,6 +2,7 @@ from github import GithubException
|
|
|
2
2
|
from threading import Lock, Event, Thread
|
|
3
3
|
from datetime import datetime, timezone
|
|
4
4
|
from dataclasses import dataclass, field
|
|
5
|
+
from enum import Enum
|
|
5
6
|
|
|
6
7
|
from typing import Optional, Dict, Any, List, Callable
|
|
7
8
|
from llama_github.logger import logger
|
|
@@ -18,10 +19,167 @@ import requests
|
|
|
18
19
|
from llama_github.config.config import config
|
|
19
20
|
from llama_github.utils import DiffGenerator, CodeAnalyzer
|
|
20
21
|
|
|
21
|
-
__all__ = [
|
|
22
|
+
__all__ = [
|
|
23
|
+
"BoundedTextReadOptIn",
|
|
24
|
+
"BoundedTextReadOutcome",
|
|
25
|
+
"BoundedTextReadResult",
|
|
26
|
+
"CIStatusSnapshot",
|
|
27
|
+
"Repository",
|
|
28
|
+
"RepositoryPool",
|
|
29
|
+
]
|
|
22
30
|
|
|
23
31
|
|
|
24
32
|
DEFAULT_RELATED_ISSUE_MAX_HITS = 20
|
|
33
|
+
BOUNDED_TEXT_SOURCE_MAX_BYTES = 2 * 1024 * 1024
|
|
34
|
+
|
|
35
|
+
_DEPENDENCY_LOCK_BASENAMES = frozenset(
|
|
36
|
+
{
|
|
37
|
+
"package-lock.json",
|
|
38
|
+
"npm-shrinkwrap.json",
|
|
39
|
+
"pnpm-lock.yaml",
|
|
40
|
+
"pnpm-lock.yml",
|
|
41
|
+
"yarn.lock",
|
|
42
|
+
"bun.lock",
|
|
43
|
+
"bun.lockb",
|
|
44
|
+
"uv.lock",
|
|
45
|
+
"poetry.lock",
|
|
46
|
+
"pipfile.lock",
|
|
47
|
+
"go.sum",
|
|
48
|
+
"cargo.lock",
|
|
49
|
+
"gradle.lockfile",
|
|
50
|
+
"gemfile.lock",
|
|
51
|
+
"composer.lock",
|
|
52
|
+
"pubspec.lock",
|
|
53
|
+
"mix.lock",
|
|
54
|
+
"flake.lock",
|
|
55
|
+
"renv.lock",
|
|
56
|
+
"package.resolved",
|
|
57
|
+
"packages.lock.json",
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class BoundedTextReadOutcome(str, Enum):
|
|
63
|
+
"""Truthful terminal outcome for one bounded repository text read."""
|
|
64
|
+
|
|
65
|
+
SUCCESS = "success"
|
|
66
|
+
EXCLUDED_BY_POLICY = "excluded_by_policy"
|
|
67
|
+
NOT_FOUND = "not_found"
|
|
68
|
+
OVERSIZE = "oversize"
|
|
69
|
+
BINARY_OR_NON_UTF8 = "binary_or_non_utf8"
|
|
70
|
+
DIRECTORY = "directory"
|
|
71
|
+
ERROR = "error"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class BoundedTextReadOptIn(str, Enum):
|
|
75
|
+
"""Narrow high-intent exceptions to the default repository-read policy."""
|
|
76
|
+
|
|
77
|
+
DEPENDENCY_LOCK = "dependency_lock"
|
|
78
|
+
CI_CONFIG = "ci_config"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True)
|
|
82
|
+
class BoundedTextReadResult:
|
|
83
|
+
"""Text plus bounded, JSON-safe retrieval metadata."""
|
|
84
|
+
|
|
85
|
+
outcome: BoundedTextReadOutcome
|
|
86
|
+
content: Optional[str] = None
|
|
87
|
+
source_size_bytes: Optional[int] = None
|
|
88
|
+
bytes_read: int = 0
|
|
89
|
+
max_bytes: int = BOUNDED_TEXT_SOURCE_MAX_BYTES
|
|
90
|
+
policy_class: Optional[str] = None
|
|
91
|
+
status_code: Optional[int] = None
|
|
92
|
+
error_type: Optional[str] = None
|
|
93
|
+
|
|
94
|
+
def to_meta(self) -> dict:
|
|
95
|
+
"""Return metadata only; never include repository text."""
|
|
96
|
+
return {
|
|
97
|
+
"outcome": self.outcome.value,
|
|
98
|
+
"source_size_bytes": self.source_size_bytes,
|
|
99
|
+
"bytes_read": self.bytes_read,
|
|
100
|
+
"max_bytes": self.max_bytes,
|
|
101
|
+
"policy_class": self.policy_class,
|
|
102
|
+
"status_code": self.status_code,
|
|
103
|
+
"error_type": self.error_type,
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _normalized_repo_path(file_path: str) -> str:
|
|
108
|
+
return "/" + str(file_path or "").replace("\\", "/").lstrip("/")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _bounded_text_policy_class(file_path: str) -> Optional[str]:
|
|
112
|
+
"""Classify paths that the bounded API may not read by default."""
|
|
113
|
+
normalized = _normalized_repo_path(file_path)
|
|
114
|
+
lowered = normalized.casefold()
|
|
115
|
+
basename = lowered.rsplit("/", 1)[-1]
|
|
116
|
+
|
|
117
|
+
if (
|
|
118
|
+
basename == ".env"
|
|
119
|
+
or (basename.startswith(".env.") and basename not in {".env.example", ".env.sample"})
|
|
120
|
+
or basename in {"id_rsa", "id_ed25519", "credentials", "secrets.yml", "secrets.yaml"}
|
|
121
|
+
or any(part in lowered for part in ("/.git/", "/.aws/", "/.ssh/"))
|
|
122
|
+
):
|
|
123
|
+
return "sensitive"
|
|
124
|
+
|
|
125
|
+
if (
|
|
126
|
+
basename.endswith(".lock")
|
|
127
|
+
or basename.endswith(".lockfile")
|
|
128
|
+
or basename.endswith(".lock.json")
|
|
129
|
+
or basename.endswith("-lock.json")
|
|
130
|
+
or basename.endswith("_lock.json")
|
|
131
|
+
or basename in _DEPENDENCY_LOCK_BASENAMES
|
|
132
|
+
):
|
|
133
|
+
return BoundedTextReadOptIn.DEPENDENCY_LOCK.value
|
|
134
|
+
|
|
135
|
+
if (
|
|
136
|
+
lowered.startswith("/.github/workflows/")
|
|
137
|
+
or lowered.startswith("/.gitlab/ci/")
|
|
138
|
+
or lowered.startswith("/.circleci/")
|
|
139
|
+
or lowered.startswith("/.buildkite/")
|
|
140
|
+
or basename
|
|
141
|
+
in {
|
|
142
|
+
".gitlab-ci.yml",
|
|
143
|
+
".gitlab-ci.yaml",
|
|
144
|
+
".travis.yml",
|
|
145
|
+
".travis.yaml",
|
|
146
|
+
"azure-pipelines.yml",
|
|
147
|
+
"azure-pipelines.yaml",
|
|
148
|
+
"bitbucket-pipelines.yml",
|
|
149
|
+
"bitbucket-pipelines.yaml",
|
|
150
|
+
"jenkinsfile",
|
|
151
|
+
"appveyor.yml",
|
|
152
|
+
"appveyor.yaml",
|
|
153
|
+
}
|
|
154
|
+
):
|
|
155
|
+
return BoundedTextReadOptIn.CI_CONFIG.value
|
|
156
|
+
|
|
157
|
+
if any(
|
|
158
|
+
basename.endswith(suffix)
|
|
159
|
+
for suffix in (
|
|
160
|
+
".exe", ".dll", ".so", ".dylib", ".bin", ".obj", ".o", ".a",
|
|
161
|
+
".lib", ".jar", ".war", ".ear", ".class", ".apk", ".wasm",
|
|
162
|
+
".png", ".jpg", ".jpeg", ".gif", ".ico", ".bmp", ".webp",
|
|
163
|
+
".mp3", ".mp4", ".wav", ".avi", ".mov", ".mkv", ".webm",
|
|
164
|
+
".ttf", ".otf", ".woff", ".woff2", ".zip", ".rar", ".7z",
|
|
165
|
+
".tar", ".gz", ".bz2", ".xz", ".pkl", ".pickle", ".npy",
|
|
166
|
+
".npz", ".h5", ".hdf5", ".pyc", ".pyo", ".min.js", ".min.css",
|
|
167
|
+
".bundle.js", ".bundle.css", ".map", ".pbix", ".pbit", ".abf",
|
|
168
|
+
)
|
|
169
|
+
):
|
|
170
|
+
return "binary_generated_or_minified"
|
|
171
|
+
|
|
172
|
+
if any(
|
|
173
|
+
segment in lowered
|
|
174
|
+
for segment in (
|
|
175
|
+
"/node_modules/", "/bower_components/", "/__pycache__/", "/.git/",
|
|
176
|
+
"/coverage/", "/.pytest_cache/", "/.tox/", "/venv/",
|
|
177
|
+
"/.virtualenv/", "/.cache/", "/.dart_tool/", "/.next/",
|
|
178
|
+
"/.nuxt/", "/.ipynb_checkpoints/",
|
|
179
|
+
)
|
|
180
|
+
):
|
|
181
|
+
return "binary_generated_or_minified"
|
|
182
|
+
return None
|
|
25
183
|
|
|
26
184
|
|
|
27
185
|
@dataclass(frozen=True)
|
|
@@ -114,6 +272,7 @@ class Repository:
|
|
|
114
272
|
|
|
115
273
|
self._structure = None # Singleton pattern for repository structure
|
|
116
274
|
self._file_contents = {} # Singleton pattern for file contents
|
|
275
|
+
self._bounded_text_reads = {}
|
|
117
276
|
self._issues = {} # Singleton pattern for file contents
|
|
118
277
|
self._readme = None # Singleton pattern for README content
|
|
119
278
|
self._prs = {} # Singleton pattern for PR content
|
|
@@ -604,6 +763,201 @@ class Repository:
|
|
|
604
763
|
self.update_last_read_time()
|
|
605
764
|
return self._file_contents.get(file_key)
|
|
606
765
|
|
|
766
|
+
def read_text_file_bounded(
|
|
767
|
+
self,
|
|
768
|
+
file_path: str,
|
|
769
|
+
sha: Optional[str] = None,
|
|
770
|
+
*,
|
|
771
|
+
opt_in: Optional[BoundedTextReadOptIn] = None,
|
|
772
|
+
) -> BoundedTextReadResult:
|
|
773
|
+
"""Read one UTF-8 file under a fixed 2 MiB source cap.
|
|
774
|
+
|
|
775
|
+
Dependency lockfiles and CI configuration require an exact, typed
|
|
776
|
+
``opt_in``. Other binary, generated, minified, or sensitive paths stay
|
|
777
|
+
excluded even when an opt-in is supplied. The legacy
|
|
778
|
+
:meth:`get_file_content` contract and cache are intentionally separate.
|
|
779
|
+
"""
|
|
780
|
+
requested_opt_in = None
|
|
781
|
+
if opt_in is not None:
|
|
782
|
+
requested_opt_in = BoundedTextReadOptIn(opt_in).value
|
|
783
|
+
|
|
784
|
+
policy_class = _bounded_text_policy_class(file_path)
|
|
785
|
+
allowed_exception = policy_class in {
|
|
786
|
+
BoundedTextReadOptIn.DEPENDENCY_LOCK.value,
|
|
787
|
+
BoundedTextReadOptIn.CI_CONFIG.value,
|
|
788
|
+
}
|
|
789
|
+
if (
|
|
790
|
+
(allowed_exception and requested_opt_in != policy_class)
|
|
791
|
+
or (policy_class is not None and not allowed_exception)
|
|
792
|
+
or (policy_class is None and requested_opt_in is not None)
|
|
793
|
+
):
|
|
794
|
+
return BoundedTextReadResult(
|
|
795
|
+
outcome=BoundedTextReadOutcome.EXCLUDED_BY_POLICY,
|
|
796
|
+
policy_class=policy_class or "opt_in_path_mismatch",
|
|
797
|
+
)
|
|
798
|
+
|
|
799
|
+
cache_key = (str(file_path), sha, requested_opt_in)
|
|
800
|
+
cached = self._bounded_text_reads.get(cache_key)
|
|
801
|
+
if cached is not None:
|
|
802
|
+
return cached
|
|
803
|
+
|
|
804
|
+
try:
|
|
805
|
+
repo = self.repo
|
|
806
|
+
if repo is None:
|
|
807
|
+
return BoundedTextReadResult(
|
|
808
|
+
outcome=BoundedTextReadOutcome.ERROR,
|
|
809
|
+
policy_class=policy_class,
|
|
810
|
+
error_type="repository_unavailable",
|
|
811
|
+
)
|
|
812
|
+
file_content = (
|
|
813
|
+
repo.get_contents(file_path, ref=sha)
|
|
814
|
+
if sha is not None
|
|
815
|
+
else repo.get_contents(file_path)
|
|
816
|
+
)
|
|
817
|
+
except GithubException as exc:
|
|
818
|
+
status_code = getattr(exc, "status", None)
|
|
819
|
+
return BoundedTextReadResult(
|
|
820
|
+
outcome=(
|
|
821
|
+
BoundedTextReadOutcome.NOT_FOUND
|
|
822
|
+
if status_code == 404
|
|
823
|
+
else BoundedTextReadOutcome.ERROR
|
|
824
|
+
),
|
|
825
|
+
policy_class=policy_class,
|
|
826
|
+
status_code=status_code,
|
|
827
|
+
error_type=(
|
|
828
|
+
"http_404"
|
|
829
|
+
if status_code == 404
|
|
830
|
+
else type(exc).__name__
|
|
831
|
+
),
|
|
832
|
+
)
|
|
833
|
+
except Exception as exc:
|
|
834
|
+
logger.warning(
|
|
835
|
+
"Bounded text read failed operation=get_contents error_type=%s",
|
|
836
|
+
type(exc).__name__,
|
|
837
|
+
)
|
|
838
|
+
return BoundedTextReadResult(
|
|
839
|
+
outcome=BoundedTextReadOutcome.ERROR,
|
|
840
|
+
policy_class=policy_class,
|
|
841
|
+
error_type=type(exc).__name__,
|
|
842
|
+
)
|
|
843
|
+
|
|
844
|
+
try:
|
|
845
|
+
is_directory = (
|
|
846
|
+
isinstance(file_content, list)
|
|
847
|
+
or getattr(file_content, "type", None) == "dir"
|
|
848
|
+
)
|
|
849
|
+
raw_size = getattr(file_content, "size", None)
|
|
850
|
+
except Exception as exc:
|
|
851
|
+
return BoundedTextReadResult(
|
|
852
|
+
outcome=BoundedTextReadOutcome.ERROR,
|
|
853
|
+
policy_class=policy_class,
|
|
854
|
+
error_type=type(exc).__name__,
|
|
855
|
+
)
|
|
856
|
+
if is_directory:
|
|
857
|
+
return BoundedTextReadResult(
|
|
858
|
+
outcome=BoundedTextReadOutcome.DIRECTORY,
|
|
859
|
+
policy_class=policy_class,
|
|
860
|
+
)
|
|
861
|
+
|
|
862
|
+
source_size = (
|
|
863
|
+
raw_size
|
|
864
|
+
if isinstance(raw_size, int) and not isinstance(raw_size, bool) and raw_size >= 0
|
|
865
|
+
else None
|
|
866
|
+
)
|
|
867
|
+
if source_size is not None and source_size > BOUNDED_TEXT_SOURCE_MAX_BYTES:
|
|
868
|
+
return BoundedTextReadResult(
|
|
869
|
+
outcome=BoundedTextReadOutcome.OVERSIZE,
|
|
870
|
+
source_size_bytes=source_size,
|
|
871
|
+
max_bytes=BOUNDED_TEXT_SOURCE_MAX_BYTES,
|
|
872
|
+
policy_class=policy_class,
|
|
873
|
+
)
|
|
874
|
+
|
|
875
|
+
try:
|
|
876
|
+
encoding = getattr(file_content, "encoding", None)
|
|
877
|
+
if encoding == "base64":
|
|
878
|
+
encoded = file_content.content
|
|
879
|
+
if not isinstance(encoded, str):
|
|
880
|
+
raise ValueError("base64 content is not text")
|
|
881
|
+
raw_bytes = base64.b64decode(encoded)
|
|
882
|
+
bytes_read = len(raw_bytes)
|
|
883
|
+
status_code = 200
|
|
884
|
+
else:
|
|
885
|
+
raw_url = getattr(file_content, "url", None)
|
|
886
|
+
if not isinstance(raw_url, str) or not raw_url:
|
|
887
|
+
raw_url = getattr(file_content, "download_url", None)
|
|
888
|
+
bounded_request = getattr(self._github, "_request_bounded_bytes", None)
|
|
889
|
+
if not isinstance(raw_url, str) or not raw_url or not callable(bounded_request):
|
|
890
|
+
return BoundedTextReadResult(
|
|
891
|
+
outcome=BoundedTextReadOutcome.ERROR,
|
|
892
|
+
source_size_bytes=source_size,
|
|
893
|
+
policy_class=policy_class,
|
|
894
|
+
error_type="raw_transport_unavailable",
|
|
895
|
+
)
|
|
896
|
+
response = bounded_request(
|
|
897
|
+
raw_url,
|
|
898
|
+
max_bytes=BOUNDED_TEXT_SOURCE_MAX_BYTES,
|
|
899
|
+
operation="bounded_text_read",
|
|
900
|
+
)
|
|
901
|
+
if response.oversize:
|
|
902
|
+
declared_size = response.declared_size_bytes
|
|
903
|
+
return BoundedTextReadResult(
|
|
904
|
+
outcome=BoundedTextReadOutcome.OVERSIZE,
|
|
905
|
+
source_size_bytes=source_size or declared_size,
|
|
906
|
+
bytes_read=response.bytes_read,
|
|
907
|
+
policy_class=policy_class,
|
|
908
|
+
status_code=response.status_code,
|
|
909
|
+
)
|
|
910
|
+
if response.error_type is not None or response.data is None:
|
|
911
|
+
return BoundedTextReadResult(
|
|
912
|
+
outcome=(
|
|
913
|
+
BoundedTextReadOutcome.NOT_FOUND
|
|
914
|
+
if response.status_code == 404
|
|
915
|
+
else BoundedTextReadOutcome.ERROR
|
|
916
|
+
),
|
|
917
|
+
source_size_bytes=source_size,
|
|
918
|
+
bytes_read=response.bytes_read,
|
|
919
|
+
policy_class=policy_class,
|
|
920
|
+
status_code=response.status_code,
|
|
921
|
+
error_type=response.error_type or "empty_raw_response",
|
|
922
|
+
)
|
|
923
|
+
raw_bytes = response.data
|
|
924
|
+
bytes_read = response.bytes_read
|
|
925
|
+
status_code = response.status_code
|
|
926
|
+
if source_size is None:
|
|
927
|
+
source_size = response.declared_size_bytes
|
|
928
|
+
|
|
929
|
+
if len(raw_bytes) > BOUNDED_TEXT_SOURCE_MAX_BYTES:
|
|
930
|
+
return BoundedTextReadResult(
|
|
931
|
+
outcome=BoundedTextReadOutcome.OVERSIZE,
|
|
932
|
+
source_size_bytes=source_size or len(raw_bytes),
|
|
933
|
+
bytes_read=len(raw_bytes),
|
|
934
|
+
policy_class=policy_class,
|
|
935
|
+
status_code=status_code,
|
|
936
|
+
)
|
|
937
|
+
if b"\x00" in raw_bytes:
|
|
938
|
+
raise UnicodeDecodeError("utf-8", raw_bytes, 0, 1, "NUL byte")
|
|
939
|
+
decoded = raw_bytes.decode("utf-8")
|
|
940
|
+
except (UnicodeDecodeError, ValueError, TypeError):
|
|
941
|
+
return BoundedTextReadResult(
|
|
942
|
+
outcome=BoundedTextReadOutcome.BINARY_OR_NON_UTF8,
|
|
943
|
+
source_size_bytes=source_size,
|
|
944
|
+
policy_class=policy_class,
|
|
945
|
+
)
|
|
946
|
+
|
|
947
|
+
result = BoundedTextReadResult(
|
|
948
|
+
outcome=BoundedTextReadOutcome.SUCCESS,
|
|
949
|
+
content=decoded,
|
|
950
|
+
source_size_bytes=source_size or len(raw_bytes),
|
|
951
|
+
bytes_read=bytes_read,
|
|
952
|
+
policy_class=policy_class,
|
|
953
|
+
status_code=status_code,
|
|
954
|
+
)
|
|
955
|
+
with self._file_contents_lock:
|
|
956
|
+
self._bounded_text_reads.setdefault(cache_key, result)
|
|
957
|
+
result = self._bounded_text_reads[cache_key]
|
|
958
|
+
self.update_last_read_time()
|
|
959
|
+
return result
|
|
960
|
+
|
|
607
961
|
|
|
608
962
|
def get_issue_content(self, number, issue=None) -> str:
|
|
609
963
|
"""
|
|
@@ -1338,6 +1692,7 @@ class Repository:
|
|
|
1338
1692
|
self._structure = None # Reset the repository structure cache
|
|
1339
1693
|
with self._file_contents_lock:
|
|
1340
1694
|
self._file_contents = {} # Reset the file contents cache
|
|
1695
|
+
self._bounded_text_reads = {}
|
|
1341
1696
|
with self._issue_lock:
|
|
1342
1697
|
self._issues = {} # Reset the issue contents cache
|
|
1343
1698
|
with self._readme_lock:
|
{llama_github-0.4.1 → llama_github-0.4.3}/llama_github/github_integration/github_auth_manager.py
RENAMED
|
@@ -54,6 +54,18 @@ class _JSONResponse:
|
|
|
54
54
|
error_type: Optional[str] = None
|
|
55
55
|
|
|
56
56
|
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class _BoundedBytesResponse:
|
|
59
|
+
"""Private transport result for a size-capped raw GitHub response."""
|
|
60
|
+
|
|
61
|
+
data: Optional[bytes] = None
|
|
62
|
+
bytes_read: int = 0
|
|
63
|
+
declared_size_bytes: Optional[int] = None
|
|
64
|
+
status_code: Optional[int] = None
|
|
65
|
+
error_type: Optional[str] = None
|
|
66
|
+
oversize: bool = False
|
|
67
|
+
|
|
68
|
+
|
|
57
69
|
class GitHubAuthManager:
|
|
58
70
|
"""Manage personal-token and GitHub-App authentication for `ExtendedGithub` clients."""
|
|
59
71
|
|
|
@@ -243,6 +255,92 @@ class ExtendedGithub(Github):
|
|
|
243
255
|
"""Compatibility helper returning decoded JSON or ``None`` on failure."""
|
|
244
256
|
return self._request_json_response(url, params).data
|
|
245
257
|
|
|
258
|
+
def _request_bounded_bytes(
|
|
259
|
+
self,
|
|
260
|
+
url: str,
|
|
261
|
+
*,
|
|
262
|
+
max_bytes: int,
|
|
263
|
+
operation: str = "github_raw_get",
|
|
264
|
+
) -> _BoundedBytesResponse:
|
|
265
|
+
"""Download at most ``max_bytes`` while retaining typed failure metadata."""
|
|
266
|
+
if max_bytes <= 0:
|
|
267
|
+
raise ValueError("max_bytes must be positive")
|
|
268
|
+
|
|
269
|
+
self._ensure_fresh_token()
|
|
270
|
+
session = self._build_session()
|
|
271
|
+
headers = {
|
|
272
|
+
"Accept": "application/vnd.github.raw+json",
|
|
273
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
274
|
+
}
|
|
275
|
+
if self._access_token:
|
|
276
|
+
headers["Authorization"] = f"Bearer {self._access_token}"
|
|
277
|
+
|
|
278
|
+
try:
|
|
279
|
+
response = session.get(
|
|
280
|
+
url,
|
|
281
|
+
headers=headers,
|
|
282
|
+
timeout=(self._connect_timeout, self._read_timeout),
|
|
283
|
+
stream=True,
|
|
284
|
+
)
|
|
285
|
+
response.raise_for_status()
|
|
286
|
+
|
|
287
|
+
declared_size = None
|
|
288
|
+
content_length = response.headers.get("Content-Length")
|
|
289
|
+
if content_length is not None:
|
|
290
|
+
try:
|
|
291
|
+
parsed_length = int(content_length)
|
|
292
|
+
if parsed_length >= 0:
|
|
293
|
+
declared_size = parsed_length
|
|
294
|
+
except (TypeError, ValueError):
|
|
295
|
+
declared_size = None
|
|
296
|
+
if declared_size is not None and declared_size > max_bytes:
|
|
297
|
+
return _BoundedBytesResponse(
|
|
298
|
+
declared_size_bytes=declared_size,
|
|
299
|
+
status_code=response.status_code,
|
|
300
|
+
oversize=True,
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
chunks = []
|
|
304
|
+
bytes_read = 0
|
|
305
|
+
for chunk in response.iter_content(chunk_size=64 * 1024):
|
|
306
|
+
if not chunk:
|
|
307
|
+
continue
|
|
308
|
+
bytes_read += len(chunk)
|
|
309
|
+
if bytes_read > max_bytes:
|
|
310
|
+
return _BoundedBytesResponse(
|
|
311
|
+
bytes_read=bytes_read,
|
|
312
|
+
declared_size_bytes=declared_size,
|
|
313
|
+
status_code=response.status_code,
|
|
314
|
+
oversize=True,
|
|
315
|
+
)
|
|
316
|
+
chunks.append(chunk)
|
|
317
|
+
return _BoundedBytesResponse(
|
|
318
|
+
data=b"".join(chunks),
|
|
319
|
+
bytes_read=bytes_read,
|
|
320
|
+
declared_size_bytes=declared_size,
|
|
321
|
+
status_code=response.status_code,
|
|
322
|
+
)
|
|
323
|
+
except HTTPError as exc:
|
|
324
|
+
status_code = getattr(exc.response, "status_code", None)
|
|
325
|
+
logger.warning(
|
|
326
|
+
"GitHub request failed operation=%s error_type=http_error status_code=%s",
|
|
327
|
+
operation,
|
|
328
|
+
status_code,
|
|
329
|
+
)
|
|
330
|
+
return _BoundedBytesResponse(
|
|
331
|
+
status_code=status_code,
|
|
332
|
+
error_type=f"http_{status_code}" if status_code else "http_error",
|
|
333
|
+
)
|
|
334
|
+
except RequestException as exc:
|
|
335
|
+
logger.warning(
|
|
336
|
+
"GitHub request failed operation=%s error_type=%s",
|
|
337
|
+
operation,
|
|
338
|
+
type(exc).__name__,
|
|
339
|
+
)
|
|
340
|
+
return _BoundedBytesResponse(error_type=type(exc).__name__)
|
|
341
|
+
finally:
|
|
342
|
+
session.close()
|
|
343
|
+
|
|
246
344
|
def _request_paginated_list(
|
|
247
345
|
self,
|
|
248
346
|
url: str,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.4.3"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: llama-github
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.3
|
|
4
4
|
Summary: GitHub retrieval and context generation utilities for coding assistants and AI developer workflows.
|
|
5
5
|
Author-email: Jet Xu <Voldemort.xu@foxmail.com>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -31,7 +31,7 @@ Requires-Dist: pydantic<3,>=2.10
|
|
|
31
31
|
Requires-Dist: python-dateutil<3,>=2.9
|
|
32
32
|
Requires-Dist: requests<3,>=2.33
|
|
33
33
|
Requires-Dist: tokenizers<1,>=0.22.1
|
|
34
|
-
Requires-Dist: transformers<6,>=5.
|
|
34
|
+
Requires-Dist: transformers<6,>=5.5
|
|
35
35
|
Requires-Dist: urllib3<3,>=2.7
|
|
36
36
|
Provides-Extra: dev
|
|
37
37
|
Requires-Dist: build<2,>=1.2; extra == "dev"
|
|
@@ -61,13 +61,14 @@ pip install llama-github
|
|
|
61
61
|
|
|
62
62
|
Current maintained runtime target: Python `3.10` through `3.14`.
|
|
63
63
|
|
|
64
|
-
Version `0.4.
|
|
64
|
+
Version `0.4.3` keeps the existing high-level API while allowing retrieval-only
|
|
65
65
|
integrations to import GitHub and diff helpers without loading the ML/RAG dependency
|
|
66
66
|
stack. Built-in Jina models are pinned to immutable revisions; custom remote-code
|
|
67
67
|
models should provide their own immutable revision. Pull-request retrieval now keeps
|
|
68
68
|
bounded fetch-health metadata, expands related issues only from PR conversation
|
|
69
69
|
evidence, and preserves review summaries plus each inline comment without collapsing
|
|
70
|
-
them together.
|
|
70
|
+
them together. Deterministic planners can explicitly opt into typed, 2 MiB-capped
|
|
71
|
+
lockfile or CI-config reads without weakening the generic file policy.
|
|
71
72
|
|
|
72
73
|
## Usage
|
|
73
74
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
[build-system]
|
|
2
|
-
requires = ["setuptools>=
|
|
2
|
+
requires = ["setuptools>=83.0.0", "wheel"]
|
|
3
3
|
build-backend = "setuptools.build_meta"
|
|
4
4
|
|
|
5
5
|
[project]
|
|
@@ -36,7 +36,7 @@ dependencies = [
|
|
|
36
36
|
"python-dateutil>=2.9,<3",
|
|
37
37
|
"requests>=2.33,<3",
|
|
38
38
|
"tokenizers>=0.22.1,<1",
|
|
39
|
-
"transformers>=5.
|
|
39
|
+
"transformers>=5.5,<6",
|
|
40
40
|
"urllib3>=2.7,<3",
|
|
41
41
|
]
|
|
42
42
|
|
|
@@ -1,11 +1,19 @@
|
|
|
1
|
+
import base64
|
|
1
2
|
from datetime import datetime, timezone
|
|
3
|
+
from types import SimpleNamespace
|
|
2
4
|
from unittest.mock import MagicMock, call, patch
|
|
3
5
|
|
|
4
6
|
import pytest
|
|
5
7
|
from github import GithubException
|
|
6
8
|
|
|
7
9
|
from llama_github.data_retrieval.github_api import GitHubAPIHandler
|
|
8
|
-
from llama_github.data_retrieval.github_entities import
|
|
10
|
+
from llama_github.data_retrieval.github_entities import (
|
|
11
|
+
BOUNDED_TEXT_SOURCE_MAX_BYTES,
|
|
12
|
+
BoundedTextReadOptIn,
|
|
13
|
+
BoundedTextReadOutcome,
|
|
14
|
+
Repository,
|
|
15
|
+
RepositoryPool,
|
|
16
|
+
)
|
|
9
17
|
from llama_github.github_integration.github_auth_manager import (
|
|
10
18
|
RetrievalOutcome,
|
|
11
19
|
RetrievalResult,
|
|
@@ -48,6 +56,195 @@ class TestRepository:
|
|
|
48
56
|
|
|
49
57
|
assert content == 'print("hello")'
|
|
50
58
|
|
|
59
|
+
def test_bounded_text_read_preserves_legacy_lockfile_exclusion(
|
|
60
|
+
self, mock_github_instance, mock_repo_object
|
|
61
|
+
):
|
|
62
|
+
mock_github_instance.get_repo.return_value = mock_repo_object
|
|
63
|
+
repo = Repository("owner/test-repo", mock_github_instance)
|
|
64
|
+
|
|
65
|
+
assert repo.get_file_content("package-lock.json") is None
|
|
66
|
+
result = repo.read_text_file_bounded("package-lock.json")
|
|
67
|
+
|
|
68
|
+
assert result.outcome is BoundedTextReadOutcome.EXCLUDED_BY_POLICY
|
|
69
|
+
assert result.policy_class == "dependency_lock"
|
|
70
|
+
mock_repo_object.get_contents.assert_not_called()
|
|
71
|
+
|
|
72
|
+
@pytest.mark.parametrize(
|
|
73
|
+
("path", "opt_in", "expected_policy"),
|
|
74
|
+
[
|
|
75
|
+
("uv.lock", BoundedTextReadOptIn.DEPENDENCY_LOCK, "dependency_lock"),
|
|
76
|
+
("go.sum", BoundedTextReadOptIn.DEPENDENCY_LOCK, "dependency_lock"),
|
|
77
|
+
("gradle.lockfile", BoundedTextReadOptIn.DEPENDENCY_LOCK, "dependency_lock"),
|
|
78
|
+
("packages.lock.json", BoundedTextReadOptIn.DEPENDENCY_LOCK, "dependency_lock"),
|
|
79
|
+
(
|
|
80
|
+
".github/workflows/test.yml",
|
|
81
|
+
BoundedTextReadOptIn.CI_CONFIG,
|
|
82
|
+
"ci_config",
|
|
83
|
+
),
|
|
84
|
+
("appveyor.yml", BoundedTextReadOptIn.CI_CONFIG, "ci_config"),
|
|
85
|
+
],
|
|
86
|
+
)
|
|
87
|
+
def test_bounded_text_read_allows_only_exact_high_intent_opt_in(
|
|
88
|
+
self,
|
|
89
|
+
mock_github_instance,
|
|
90
|
+
mock_repo_object,
|
|
91
|
+
path,
|
|
92
|
+
opt_in,
|
|
93
|
+
expected_policy,
|
|
94
|
+
):
|
|
95
|
+
mock_github_instance.get_repo.return_value = mock_repo_object
|
|
96
|
+
content = b"version = 1\n"
|
|
97
|
+
file_obj = SimpleNamespace(
|
|
98
|
+
type="file",
|
|
99
|
+
size=len(content),
|
|
100
|
+
encoding="base64",
|
|
101
|
+
content=base64.b64encode(content).decode("ascii"),
|
|
102
|
+
)
|
|
103
|
+
mock_repo_object.get_contents.return_value = file_obj
|
|
104
|
+
repo = Repository("owner/test-repo", mock_github_instance)
|
|
105
|
+
|
|
106
|
+
result = repo.read_text_file_bounded(path, sha="head", opt_in=opt_in)
|
|
107
|
+
|
|
108
|
+
assert result.outcome is BoundedTextReadOutcome.SUCCESS
|
|
109
|
+
assert result.content == content.decode("utf-8")
|
|
110
|
+
assert result.policy_class == expected_policy
|
|
111
|
+
assert result.source_size_bytes == len(content)
|
|
112
|
+
assert result.bytes_read == len(content)
|
|
113
|
+
assert "content" not in result.to_meta()
|
|
114
|
+
mock_repo_object.get_contents.assert_called_once_with(path, ref="head")
|
|
115
|
+
|
|
116
|
+
@pytest.mark.parametrize(
|
|
117
|
+
("path", "opt_in"),
|
|
118
|
+
[
|
|
119
|
+
("uv.lock", BoundedTextReadOptIn.CI_CONFIG),
|
|
120
|
+
("src/main.py", BoundedTextReadOptIn.CI_CONFIG),
|
|
121
|
+
(".env", None),
|
|
122
|
+
("dist/app.min.js", None),
|
|
123
|
+
],
|
|
124
|
+
)
|
|
125
|
+
def test_bounded_text_read_rejects_mismatched_or_non_text_policy(
|
|
126
|
+
self, mock_github_instance, mock_repo_object, path, opt_in
|
|
127
|
+
):
|
|
128
|
+
mock_github_instance.get_repo.return_value = mock_repo_object
|
|
129
|
+
repo = Repository("owner/test-repo", mock_github_instance)
|
|
130
|
+
|
|
131
|
+
result = repo.read_text_file_bounded(path, opt_in=opt_in)
|
|
132
|
+
|
|
133
|
+
assert result.outcome is BoundedTextReadOutcome.EXCLUDED_BY_POLICY
|
|
134
|
+
mock_repo_object.get_contents.assert_not_called()
|
|
135
|
+
|
|
136
|
+
def test_bounded_text_read_rejects_oversize_before_content_access(
|
|
137
|
+
self, mock_github_instance, mock_repo_object
|
|
138
|
+
):
|
|
139
|
+
mock_github_instance.get_repo.return_value = mock_repo_object
|
|
140
|
+
file_obj = MagicMock()
|
|
141
|
+
file_obj.type = "file"
|
|
142
|
+
file_obj.size = BOUNDED_TEXT_SOURCE_MAX_BYTES + 1
|
|
143
|
+
mock_repo_object.get_contents.return_value = file_obj
|
|
144
|
+
repo = Repository("owner/test-repo", mock_github_instance)
|
|
145
|
+
|
|
146
|
+
result = repo.read_text_file_bounded("src/large.txt")
|
|
147
|
+
|
|
148
|
+
assert result.outcome is BoundedTextReadOutcome.OVERSIZE
|
|
149
|
+
assert result.source_size_bytes == BOUNDED_TEXT_SOURCE_MAX_BYTES + 1
|
|
150
|
+
assert file_obj.content.call_count == 0
|
|
151
|
+
|
|
152
|
+
@pytest.mark.parametrize(
|
|
153
|
+
("payload_size", "expected"),
|
|
154
|
+
[
|
|
155
|
+
(BOUNDED_TEXT_SOURCE_MAX_BYTES, BoundedTextReadOutcome.SUCCESS),
|
|
156
|
+
(BOUNDED_TEXT_SOURCE_MAX_BYTES + 1, BoundedTextReadOutcome.OVERSIZE),
|
|
157
|
+
],
|
|
158
|
+
)
|
|
159
|
+
def test_bounded_text_read_enforces_local_cap_after_decode(
|
|
160
|
+
self, mock_github_instance, mock_repo_object, payload_size, expected
|
|
161
|
+
):
|
|
162
|
+
mock_github_instance.get_repo.return_value = mock_repo_object
|
|
163
|
+
payload = b"a" * payload_size
|
|
164
|
+
mock_repo_object.get_contents.return_value = SimpleNamespace(
|
|
165
|
+
type="file",
|
|
166
|
+
size=BOUNDED_TEXT_SOURCE_MAX_BYTES,
|
|
167
|
+
encoding="base64",
|
|
168
|
+
content=base64.b64encode(payload).decode("ascii"),
|
|
169
|
+
)
|
|
170
|
+
repo = Repository("owner/test-repo", mock_github_instance)
|
|
171
|
+
|
|
172
|
+
result = repo.read_text_file_bounded("src/large.txt")
|
|
173
|
+
|
|
174
|
+
assert result.outcome is expected
|
|
175
|
+
|
|
176
|
+
@pytest.mark.parametrize("payload", [b"\xff\xfe", b"text\x00tail"])
|
|
177
|
+
def test_bounded_text_read_rejects_binary_or_non_utf8(
|
|
178
|
+
self, mock_github_instance, mock_repo_object, payload
|
|
179
|
+
):
|
|
180
|
+
mock_github_instance.get_repo.return_value = mock_repo_object
|
|
181
|
+
mock_repo_object.get_contents.return_value = SimpleNamespace(
|
|
182
|
+
type="file",
|
|
183
|
+
size=len(payload),
|
|
184
|
+
encoding="base64",
|
|
185
|
+
content=base64.b64encode(payload).decode("ascii"),
|
|
186
|
+
)
|
|
187
|
+
repo = Repository("owner/test-repo", mock_github_instance)
|
|
188
|
+
|
|
189
|
+
result = repo.read_text_file_bounded("src/data.txt")
|
|
190
|
+
|
|
191
|
+
assert result.outcome is BoundedTextReadOutcome.BINARY_OR_NON_UTF8
|
|
192
|
+
|
|
193
|
+
def test_bounded_text_read_distinguishes_directory_not_found_and_error(
|
|
194
|
+
self, mock_github_instance, mock_repo_object
|
|
195
|
+
):
|
|
196
|
+
mock_github_instance.get_repo.return_value = mock_repo_object
|
|
197
|
+
repo = Repository("owner/test-repo", mock_github_instance)
|
|
198
|
+
|
|
199
|
+
mock_repo_object.get_contents.return_value = []
|
|
200
|
+
assert (
|
|
201
|
+
repo.read_text_file_bounded("src").outcome
|
|
202
|
+
is BoundedTextReadOutcome.DIRECTORY
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
mock_repo_object.get_contents.side_effect = GithubException(404, {"message": "missing"})
|
|
206
|
+
assert (
|
|
207
|
+
repo.read_text_file_bounded("missing.txt").outcome
|
|
208
|
+
is BoundedTextReadOutcome.NOT_FOUND
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
mock_repo_object.get_contents.side_effect = GithubException(503, {"message": "down"})
|
|
212
|
+
assert (
|
|
213
|
+
repo.read_text_file_bounded("unavailable.txt").outcome
|
|
214
|
+
is BoundedTextReadOutcome.ERROR
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
def test_bounded_text_read_maps_raw_transport_outcomes(
|
|
218
|
+
self, mock_github_instance, mock_repo_object
|
|
219
|
+
):
|
|
220
|
+
mock_github_instance.get_repo.return_value = mock_repo_object
|
|
221
|
+
mock_repo_object.get_contents.return_value = SimpleNamespace(
|
|
222
|
+
type="file",
|
|
223
|
+
size=None,
|
|
224
|
+
encoding="none",
|
|
225
|
+
url="https://api.github.test/contents/src/config.txt",
|
|
226
|
+
download_url=None,
|
|
227
|
+
)
|
|
228
|
+
mock_github_instance._request_bounded_bytes.return_value = SimpleNamespace(
|
|
229
|
+
data=b"tail = true\n",
|
|
230
|
+
bytes_read=12,
|
|
231
|
+
declared_size_bytes=12,
|
|
232
|
+
status_code=200,
|
|
233
|
+
error_type=None,
|
|
234
|
+
oversize=False,
|
|
235
|
+
)
|
|
236
|
+
repo = Repository("owner/test-repo", mock_github_instance)
|
|
237
|
+
|
|
238
|
+
result = repo.read_text_file_bounded("src/config.txt")
|
|
239
|
+
|
|
240
|
+
assert result.outcome is BoundedTextReadOutcome.SUCCESS
|
|
241
|
+
assert result.content == "tail = true\n"
|
|
242
|
+
mock_github_instance._request_bounded_bytes.assert_called_once_with(
|
|
243
|
+
"https://api.github.test/contents/src/config.txt",
|
|
244
|
+
max_bytes=BOUNDED_TEXT_SOURCE_MAX_BYTES,
|
|
245
|
+
operation="bounded_text_read",
|
|
246
|
+
)
|
|
247
|
+
|
|
51
248
|
def test_get_pr_content_includes_current_head_sha(
|
|
52
249
|
self, mock_github_instance, mock_repo_object
|
|
53
250
|
):
|
|
@@ -16,20 +16,20 @@ def _write_wheel(path: Path, *, extra_files=()) -> None:
|
|
|
16
16
|
with zipfile.ZipFile(path, "w") as archive:
|
|
17
17
|
for name in sorted(expected_package_files(ROOT)):
|
|
18
18
|
archive.writestr(name, b"")
|
|
19
|
-
archive.writestr("llama_github-0.4.
|
|
19
|
+
archive.writestr("llama_github-0.4.3.dist-info/METADATA", b"")
|
|
20
20
|
for name in extra_files:
|
|
21
21
|
archive.writestr(name, b"")
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
def test_wheel_content_verifier_accepts_exact_source_package(tmp_path):
|
|
25
|
-
wheel = tmp_path / "llama_github-0.4.
|
|
25
|
+
wheel = tmp_path / "llama_github-0.4.3-py3-none-any.whl"
|
|
26
26
|
_write_wheel(wheel)
|
|
27
27
|
|
|
28
28
|
verify_wheel(wheel, source_root=ROOT)
|
|
29
29
|
|
|
30
30
|
|
|
31
31
|
def test_wheel_content_verifier_rejects_stale_duplicate_module(tmp_path):
|
|
32
|
-
wheel = tmp_path / "llama_github-0.4.
|
|
32
|
+
wheel = tmp_path / "llama_github-0.4.3-py3-none-any.whl"
|
|
33
33
|
_write_wheel(
|
|
34
34
|
wheel,
|
|
35
35
|
extra_files=("llama_github/data_retrieval/github_entities 2.py",),
|
|
@@ -148,6 +148,67 @@ class TestExtendedGithub:
|
|
|
148
148
|
assert session.get.call_args.kwargs["timeout"] == (2.5, 9.0)
|
|
149
149
|
session.close.assert_called_once()
|
|
150
150
|
|
|
151
|
+
def test_bounded_raw_request_rejects_declared_oversize_before_stream(self):
|
|
152
|
+
gh = ExtendedGithub("token")
|
|
153
|
+
response = MagicMock()
|
|
154
|
+
response.status_code = 200
|
|
155
|
+
response.headers = {"Content-Length": "11"}
|
|
156
|
+
session = MagicMock()
|
|
157
|
+
session.get.return_value = response
|
|
158
|
+
gh._build_session = MagicMock(return_value=session)
|
|
159
|
+
|
|
160
|
+
result = gh._request_bounded_bytes(
|
|
161
|
+
"https://api.github.com/example",
|
|
162
|
+
max_bytes=10,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
assert result.oversize is True
|
|
166
|
+
assert result.declared_size_bytes == 11
|
|
167
|
+
response.iter_content.assert_not_called()
|
|
168
|
+
assert session.get.call_args.kwargs["stream"] is True
|
|
169
|
+
session.close.assert_called_once()
|
|
170
|
+
|
|
171
|
+
def test_bounded_raw_request_rejects_stream_that_crosses_cap(self):
|
|
172
|
+
gh = ExtendedGithub("token")
|
|
173
|
+
response = MagicMock()
|
|
174
|
+
response.status_code = 200
|
|
175
|
+
response.headers = {}
|
|
176
|
+
response.iter_content.return_value = [b"123456", b"78901"]
|
|
177
|
+
session = MagicMock()
|
|
178
|
+
session.get.return_value = response
|
|
179
|
+
gh._build_session = MagicMock(return_value=session)
|
|
180
|
+
|
|
181
|
+
result = gh._request_bounded_bytes(
|
|
182
|
+
"https://api.github.com/example",
|
|
183
|
+
max_bytes=10,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
assert result.oversize is True
|
|
187
|
+
assert result.bytes_read == 11
|
|
188
|
+
assert result.data is None
|
|
189
|
+
session.close.assert_called_once()
|
|
190
|
+
|
|
191
|
+
def test_bounded_raw_request_returns_exact_cap(self):
|
|
192
|
+
gh = ExtendedGithub("token")
|
|
193
|
+
response = MagicMock()
|
|
194
|
+
response.status_code = 200
|
|
195
|
+
response.headers = {"Content-Length": "10"}
|
|
196
|
+
response.iter_content.return_value = [b"12345", b"67890"]
|
|
197
|
+
session = MagicMock()
|
|
198
|
+
session.get.return_value = response
|
|
199
|
+
gh._build_session = MagicMock(return_value=session)
|
|
200
|
+
|
|
201
|
+
result = gh._request_bounded_bytes(
|
|
202
|
+
"https://api.github.com/example",
|
|
203
|
+
max_bytes=10,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
assert result.oversize is False
|
|
207
|
+
assert result.data == b"1234567890"
|
|
208
|
+
assert result.bytes_read == 10
|
|
209
|
+
assert result.declared_size_bytes == 10
|
|
210
|
+
session.close.assert_called_once()
|
|
211
|
+
|
|
151
212
|
def test_refresh_token_updates_requester(self):
|
|
152
213
|
gh = ExtendedGithub("token-1")
|
|
153
214
|
gh.refresh_token("token-2")
|
|
@@ -12,7 +12,7 @@ def test_retrieval_imports_do_not_load_heavy_rag_dependencies():
|
|
|
12
12
|
assert "llama_github.github_rag" not in sys.modules
|
|
13
13
|
|
|
14
14
|
from llama_github.data_retrieval.github_api import GitHubAPIHandler
|
|
15
|
-
from llama_github.data_retrieval.github_entities import CIStatusSnapshot, Repository, RepositoryPool
|
|
15
|
+
from llama_github.data_retrieval.github_entities import BoundedTextReadResult, CIStatusSnapshot, Repository, RepositoryPool
|
|
16
16
|
from llama_github.github_integration.github_auth_manager import GitHubAuthManager
|
|
17
17
|
from llama_github.utils import AsyncHTTPClient, DiffGenerator
|
|
18
18
|
|
|
@@ -20,7 +20,9 @@ def test_retrieval_imports_do_not_load_heavy_rag_dependencies():
|
|
|
20
20
|
loaded = forbidden.intersection(sys.modules)
|
|
21
21
|
assert not loaded, sorted(loaded)
|
|
22
22
|
assert callable(Repository.get_ci_status_with_status)
|
|
23
|
-
assert
|
|
23
|
+
assert callable(Repository.read_text_file_bounded)
|
|
24
|
+
assert BoundedTextReadResult.__name__ == "BoundedTextReadResult"
|
|
25
|
+
assert llama_github.__version__ == "0.4.3"
|
|
24
26
|
"""
|
|
25
27
|
)
|
|
26
28
|
|
|
File without changes
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.4.1"
|
|
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
|