witan-code 0.16.0__tar.gz → 0.17.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.
- {witan_code-0.16.0 → witan_code-0.17.0}/CHANGELOG.md +11 -1
- {witan_code-0.16.0 → witan_code-0.17.0}/PKG-INFO +3 -3
- {witan_code-0.16.0 → witan_code-0.17.0}/pyproject.toml +6 -7
- {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_github_app.py +219 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/github_app.py +173 -12
- {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/server.py +2 -2
- {witan_code-0.16.0 → witan_code-0.17.0}/.gitignore +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/README.md +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/config/claude.json +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/config/copilot.json +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/config/pi.json +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/conftest.py +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/docs/BRANCH_INDEXING.md +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/docs/CLI_REFERENCE.md +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/docs/EDGE_PRECISION_TIERS.md +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/docs/PACKAGE_MAP.md +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/docs/STAGE2_STITCHING.md +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/docs/SYMBOL_FORMAT.md +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/docs/SYMBOL_TABLE.md +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/docs/USER_GUIDE.md +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/install.sh +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/tests/__init__.py +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/tests/conftest.py +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_batched_deletes.py +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_branches.py +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_bridge.py +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_chunked_load.py +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_cli_dispatch.py +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_cli_hooks.py +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_cli_output_format.py +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_config.py +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_config_targets.py +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_context.py +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_crossrepo.py +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_edges.py +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_elicit.py +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_enrichment.py +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_graph.py +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_hooks.py +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_identity.py +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_index_failure_diagnostics.py +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_indexer.py +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_indexer_scope.py +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_ingest.py +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_languages.py +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_maintenance.py +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_package_map.py +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_reaper.py +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_remote_proxy.py +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_remote_store.py +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_repo.py +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_server.py +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_setup.py +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_stitch.py +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_store.py +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_symbol_table.py +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_tasks.py +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_tool_schema.py +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_views.py +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_visualize.py +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/__init__.py +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/__main__.py +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/bridge.py +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/bridge_extractors.py +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/cli.py +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/config.py +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/context.py +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/edges.py +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/elicit.py +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/extensions/pi/codegraph.ts +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/graph.py +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/hooks.py +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/identity.py +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/indexer.py +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/ingest.py +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/maintenance.py +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/output.py +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/package_map.py +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/queries/bridge.gq +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/queries/code_mutations.gq +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/queries/code_read.gq +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/queries/delete.gq +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/queries_ts/bash.scm +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/queries_ts/hcl.scm +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/queries_ts/python.scm +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/queries_ts/sql.scm +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/queries_ts/typescript.scm +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/queries_ts/yaml.scm +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/reaper.py +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/remote/__init__.py +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/remote/oidc.py +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/remote/proxy.py +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/remote/store.py +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/repo.py +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/schema/bridge-schema.pg +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/schema/code-schema.pg +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/setup.py +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/skills/witan-code/SKILL.md +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/stitch.py +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/store.py +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/views.py +0 -0
- {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/visualize.py +0 -0
|
@@ -6,7 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/) (pre-1.0:
|
|
7
7
|
a MINOR bump may include breaking changes).
|
|
8
8
|
|
|
9
|
-
## [
|
|
9
|
+
## [0.17.0] - 2026-09-01
|
|
10
10
|
|
|
11
11
|
### Fixed
|
|
12
12
|
|
|
@@ -55,6 +55,16 @@ a MINOR bump may include breaking changes).
|
|
|
55
55
|
|
|
56
56
|
### Changed
|
|
57
57
|
|
|
58
|
+
- **Floor raised to `fastmcp>=4,<5`; the optional `tasks` extra moves to
|
|
59
|
+
`fastmcp-tasks>=4,<5`.** FastMCP 4.0 went GA, so the `fastmcp>=3.4.2,<5`
|
|
60
|
+
straddle is dropped, along with the `witan-code[tasks]`-only reason that
|
|
61
|
+
extra used to also need `--prerelease=allow` under uv. No behavior change on
|
|
62
|
+
this package's own code — witan-core's version-sniffing shims are what
|
|
63
|
+
actually moved (see its changelog); this is a dependency-floor release,
|
|
64
|
+
breaking only for a caller still resolving fastmcp 3.4.x, which pre-1.0
|
|
65
|
+
semver treats as a MINOR bump. Closes
|
|
66
|
+
`tk-move-the-fastmcp-floor-to-4-when-4-0-goes-ga-454f78`.
|
|
67
|
+
|
|
58
68
|
- **`graph.check_writable` requires `actor`; `None` now means "no identity",
|
|
59
69
|
full stop.** It used to fall back to `identity.actor_id()`, which gave the
|
|
60
70
|
parameter two meanings depending on the caller. Every call site
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: witan-code
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.17.0
|
|
4
4
|
Summary: witan-code — tree-sitter code graph + cross-repo bridge (mounts under `witan code`)
|
|
5
5
|
Project-URL: Homepage, https://github.com/mitodl/agent-kit/tree/main/mcp/servers/witan-code
|
|
6
6
|
Project-URL: Repository, https://github.com/mitodl/agent-kit
|
|
@@ -12,7 +12,7 @@ Classifier: Programming Language :: Python :: 3
|
|
|
12
12
|
Requires-Python: >=3.11
|
|
13
13
|
Requires-Dist: agent-config-kit<1,>=0.4
|
|
14
14
|
Requires-Dist: cyclopts<5,>=4
|
|
15
|
-
Requires-Dist: fastmcp<5,>=
|
|
15
|
+
Requires-Dist: fastmcp<5,>=4
|
|
16
16
|
Requires-Dist: joserfc<2,>=1.0
|
|
17
17
|
Requires-Dist: pydantic<3,>=2
|
|
18
18
|
Requires-Dist: pyyaml<7,>=6
|
|
@@ -28,7 +28,7 @@ Requires-Dist: tree-sitter-yaml<0.8,>=0.7
|
|
|
28
28
|
Requires-Dist: tree-sitter<0.26,>=0.25
|
|
29
29
|
Requires-Dist: witan-core[cli,observability,remote,sentry]<1,>=0.32
|
|
30
30
|
Provides-Extra: tasks
|
|
31
|
-
Requires-Dist: fastmcp-tasks<5,>=4
|
|
31
|
+
Requires-Dist: fastmcp-tasks<5,>=4; extra == 'tasks'
|
|
32
32
|
Description-Content-Type: text/markdown
|
|
33
33
|
|
|
34
34
|
# witan-code
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "witan-code"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.17.0"
|
|
4
4
|
description = "witan-code — tree-sitter code graph + cross-repo bridge (mounts under `witan code`)"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "BSD-3-Clause"
|
|
@@ -122,7 +122,7 @@ dependencies = [
|
|
|
122
122
|
# conflict, since both sides named the same number. Re-check it by hand on
|
|
123
123
|
# every rebase; a floor one release low here is silent.
|
|
124
124
|
"witan-core[cli,remote,observability,sentry]>=0.32,<1",
|
|
125
|
-
"fastmcp>=
|
|
125
|
+
"fastmcp>=4,<5",
|
|
126
126
|
"cyclopts>=4,<5",
|
|
127
127
|
# [targets.<name>] override models (config.py) — also transitively pulled
|
|
128
128
|
# in by fastmcp, declared explicitly since config.py imports it directly.
|
|
@@ -162,10 +162,9 @@ dependencies = [
|
|
|
162
162
|
[project.optional-dependencies]
|
|
163
163
|
# The io.modelcontextprotocol/tasks extension (SEP-2663), which lets a client
|
|
164
164
|
# poll a long `code_reindex` instead of holding the tool call open. Optional
|
|
165
|
-
# because it
|
|
166
|
-
# and because it pulls a Docket/Redis stack that the default in-process
|
|
165
|
+
# because it pulls a Docket/Redis stack that the default in-process
|
|
167
166
|
# `memory://` backend does not need. server.py registers it when importable.
|
|
168
|
-
tasks = ["fastmcp-tasks>=4
|
|
167
|
+
tasks = ["fastmcp-tasks>=4,<5"]
|
|
169
168
|
|
|
170
169
|
[project.urls]
|
|
171
170
|
Homepage = "https://github.com/mitodl/agent-kit/tree/main/mcp/servers/witan-code"
|
|
@@ -195,7 +194,7 @@ witan-core = { workspace = true }
|
|
|
195
194
|
# cryptography: test_github_app generates a throwaway RSA key so the JWT path
|
|
196
195
|
# is exercised for real rather than mocked. joserfc requires it at runtime, so
|
|
197
196
|
# this only makes an existing import explicit.
|
|
198
|
-
test = ["pytest>=8", "fastmcp-tasks>=4
|
|
197
|
+
test = ["pytest>=8", "fastmcp-tasks>=4,<5", "cryptography"]
|
|
199
198
|
|
|
200
199
|
[tool.pytest.ini_options]
|
|
201
200
|
pythonpath = ["."]
|
|
@@ -205,7 +204,7 @@ testpaths = ["tests"]
|
|
|
205
204
|
packages = ["witan_code"]
|
|
206
205
|
|
|
207
206
|
[tool.bumpversion]
|
|
208
|
-
current_version = "0.
|
|
207
|
+
current_version = "0.17.0"
|
|
209
208
|
allow_dirty = true
|
|
210
209
|
|
|
211
210
|
[[tool.bumpversion.files]]
|
|
@@ -20,6 +20,7 @@ from witan_code.github_app import (
|
|
|
20
20
|
EXIT_ERROR,
|
|
21
21
|
EXIT_NOT_CONFIGURED,
|
|
22
22
|
EXIT_OK,
|
|
23
|
+
GH_TOKEN_ENV_VAR,
|
|
23
24
|
GITHUB_API_URL,
|
|
24
25
|
INSTALLATION_ID_ENV_VAR,
|
|
25
26
|
KEY_FILE_ENV_VAR,
|
|
@@ -27,8 +28,11 @@ from witan_code.github_app import (
|
|
|
27
28
|
GitHubAppError,
|
|
28
29
|
app_jwt,
|
|
29
30
|
from_env,
|
|
31
|
+
api_repo_host,
|
|
30
32
|
installation_token,
|
|
31
33
|
main,
|
|
34
|
+
repo_is_private,
|
|
35
|
+
repo_owner_name,
|
|
32
36
|
)
|
|
33
37
|
|
|
34
38
|
_JWT_MAX_LIFETIME = 600 # GitHub's hard limit on an App JWT.
|
|
@@ -308,3 +312,218 @@ def test_main_reports_a_mint_failure(tmp_path, pem, monkeypatch, capsys):
|
|
|
308
312
|
|
|
309
313
|
def test_main_rejects_an_unknown_argument():
|
|
310
314
|
assert main(["--nope"]) == EXIT_ERROR
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
# ── repo_owner_name / repo_is_private ─────────────────────────────────────────
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
@pytest.mark.parametrize(
|
|
321
|
+
("uri", "expected"),
|
|
322
|
+
[
|
|
323
|
+
("https://github.com/mitodl/agent-kit", ("mitodl", "agent-kit")),
|
|
324
|
+
("https://github.com/mitodl/agent-kit/", ("mitodl", "agent-kit")),
|
|
325
|
+
("https://github.com/mitodl/agent-kit.git", ("mitodl", "agent-kit")),
|
|
326
|
+
],
|
|
327
|
+
)
|
|
328
|
+
def test_repo_owner_name_splits_a_canonical_uri(uri, expected):
|
|
329
|
+
assert repo_owner_name(uri) == expected
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
@pytest.mark.parametrize(
|
|
333
|
+
"uri",
|
|
334
|
+
[
|
|
335
|
+
"agent-kit",
|
|
336
|
+
"",
|
|
337
|
+
"https://github.com/",
|
|
338
|
+
"https://github.com/mitodl",
|
|
339
|
+
# Scheme-relative: `urlsplit` gives it a netloc and no scheme, so a
|
|
340
|
+
# scheme check that only looked for "not empty" would let it through.
|
|
341
|
+
"//github.com/mitodl/agent-kit",
|
|
342
|
+
"http://github.com/mitodl/agent-kit",
|
|
343
|
+
"git@github.com:mitodl/agent-kit.git",
|
|
344
|
+
],
|
|
345
|
+
)
|
|
346
|
+
def test_repo_owner_name_rejects_something_that_is_not_a_repo_uri(uri):
|
|
347
|
+
with pytest.raises(GitHubAppError, match="owner and repository name"):
|
|
348
|
+
repo_owner_name(uri)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def test_repo_owner_name_rejects_extra_path_components():
|
|
352
|
+
"""`.../a/b/c/d` must not be cleared by `c/d`'s visibility."""
|
|
353
|
+
with pytest.raises(GitHubAppError, match="owner and repository name"):
|
|
354
|
+
repo_owner_name("https://github.com/mitodl/agent-kit/tree/main")
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def test_repo_owner_name_rejects_a_host_the_api_does_not_answer_for():
|
|
358
|
+
"""The clone uses the whole URI; the check uses only owner/name.
|
|
359
|
+
|
|
360
|
+
Left unchecked, `https://other.example/mitodl/agent-kit` would be approved
|
|
361
|
+
on `github.com/mitodl/agent-kit`'s visibility and then cloned from
|
|
362
|
+
`other.example` — the guard satisfied by a different repository entirely.
|
|
363
|
+
"""
|
|
364
|
+
with pytest.raises(GitHubAppError, match="answers for 'github.com'"):
|
|
365
|
+
repo_owner_name("https://other.example/mitodl/agent-kit")
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def test_repo_owner_name_follows_an_enterprise_api_host():
|
|
369
|
+
api = "https://ghe.example/api/v3"
|
|
370
|
+
assert repo_owner_name("https://ghe.example/mitodl/agent-kit", api_url=api) == (
|
|
371
|
+
"mitodl",
|
|
372
|
+
"agent-kit",
|
|
373
|
+
)
|
|
374
|
+
# And github.com is then the mismatched host, not the privileged one.
|
|
375
|
+
with pytest.raises(GitHubAppError, match="answers for 'ghe.example'"):
|
|
376
|
+
repo_owner_name("https://github.com/mitodl/agent-kit", api_url=api)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
@pytest.mark.parametrize(
|
|
380
|
+
("api_url", "expected"),
|
|
381
|
+
[
|
|
382
|
+
("https://api.github.com", "github.com"),
|
|
383
|
+
("https://ghe.example/api/v3", "ghe.example"),
|
|
384
|
+
("https://API.GITHUB.COM/", "github.com"),
|
|
385
|
+
],
|
|
386
|
+
)
|
|
387
|
+
def test_api_repo_host(api_url, expected):
|
|
388
|
+
assert api_repo_host(api_url) == expected
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def test_repo_is_private_asks_github_as_the_installation():
|
|
392
|
+
seen = {}
|
|
393
|
+
|
|
394
|
+
def handler(request: httpx2.Request) -> httpx2.Response:
|
|
395
|
+
seen["url"] = str(request.url)
|
|
396
|
+
seen["auth"] = request.headers["Authorization"]
|
|
397
|
+
seen["method"] = request.method
|
|
398
|
+
return httpx2.Response(200, json={"private": True})
|
|
399
|
+
|
|
400
|
+
private = repo_is_private(
|
|
401
|
+
"https://github.com/mitodl/secret-thing", "ghs_x", client=_client(handler)
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
assert private is True
|
|
405
|
+
assert seen["method"] == "GET"
|
|
406
|
+
assert seen["url"] == "https://api.github.com/repos/mitodl/secret-thing"
|
|
407
|
+
# The installation token, not the App JWT: the App itself cannot see repos.
|
|
408
|
+
assert seen["auth"] == "Bearer ghs_x"
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def test_repo_is_private_is_false_for_a_public_repo():
|
|
412
|
+
def handler(_: httpx2.Request) -> httpx2.Response:
|
|
413
|
+
return httpx2.Response(200, json={"private": False})
|
|
414
|
+
|
|
415
|
+
assert (
|
|
416
|
+
repo_is_private(
|
|
417
|
+
"https://github.com/mitodl/agent-kit", "ghs_x", client=_client(handler)
|
|
418
|
+
)
|
|
419
|
+
is False
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def test_repo_is_private_raises_on_a_404_rather_than_calling_it_public():
|
|
424
|
+
"""An installation that cannot see a repo has not answered the question."""
|
|
425
|
+
|
|
426
|
+
def handler(_: httpx2.Request) -> httpx2.Response:
|
|
427
|
+
return httpx2.Response(404, json={"message": "Not Found"})
|
|
428
|
+
|
|
429
|
+
with pytest.raises(GitHubAppError, match="would not describe"):
|
|
430
|
+
repo_is_private(
|
|
431
|
+
"https://github.com/mitodl/gone", "ghs_x", client=_client(handler)
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
@pytest.mark.parametrize("body", [{}, {"private": "yes"}])
|
|
436
|
+
def test_repo_is_private_raises_when_the_answer_is_not_a_boolean(body):
|
|
437
|
+
def handler(_: httpx2.Request) -> httpx2.Response:
|
|
438
|
+
return httpx2.Response(200, json=body)
|
|
439
|
+
|
|
440
|
+
with pytest.raises(GitHubAppError):
|
|
441
|
+
repo_is_private(
|
|
442
|
+
"https://github.com/mitodl/agent-kit", "ghs_x", client=_client(handler)
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def test_repo_is_private_raises_when_github_is_unreachable():
|
|
447
|
+
def handler(_: httpx2.Request) -> httpx2.Response:
|
|
448
|
+
raise httpx2.ConnectError("no route")
|
|
449
|
+
|
|
450
|
+
with pytest.raises(GitHubAppError, match="Could not reach"):
|
|
451
|
+
repo_is_private(
|
|
452
|
+
"https://github.com/mitodl/agent-kit", "ghs_x", client=_client(handler)
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def test_repo_is_private_leaves_a_caller_supplied_client_open():
|
|
457
|
+
client = _client(lambda _: httpx2.Response(200, json={"private": False}))
|
|
458
|
+
repo_is_private("https://github.com/mitodl/agent-kit", "ghs_x", client=client)
|
|
459
|
+
assert not client.is_closed
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
# ── main --visibility ─────────────────────────────────────────────────────────
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def test_main_visibility_prints_the_answer(tmp_path, pem, monkeypatch, capsys):
|
|
466
|
+
for name, value in _env(tmp_path, pem).items():
|
|
467
|
+
monkeypatch.setenv(name, value)
|
|
468
|
+
monkeypatch.setenv(GH_TOKEN_ENV_VAR, "ghs_already_minted")
|
|
469
|
+
seen = {}
|
|
470
|
+
|
|
471
|
+
def fake(repo, token, *, api_url):
|
|
472
|
+
seen.update(repo=repo, token=token, api_url=api_url)
|
|
473
|
+
return True
|
|
474
|
+
|
|
475
|
+
monkeypatch.setattr("witan_code.github_app.repo_is_private", fake)
|
|
476
|
+
assert main(["--visibility", "https://github.com/mitodl/x"]) == EXIT_OK
|
|
477
|
+
assert capsys.readouterr().out.strip() == "private"
|
|
478
|
+
# Reuses the token the entrypoint already minted rather than exchanging a
|
|
479
|
+
# second App JWT for another one — see GH_TOKEN_ENV_VAR.
|
|
480
|
+
assert seen["token"] == "ghs_already_minted"
|
|
481
|
+
assert seen["repo"] == "https://github.com/mitodl/x"
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def test_main_visibility_mints_a_token_when_none_is_in_the_environment(
|
|
485
|
+
tmp_path, pem, monkeypatch, capsys
|
|
486
|
+
):
|
|
487
|
+
for name, value in _env(tmp_path, pem).items():
|
|
488
|
+
monkeypatch.setenv(name, value)
|
|
489
|
+
monkeypatch.delenv(GH_TOKEN_ENV_VAR, raising=False)
|
|
490
|
+
monkeypatch.setattr(
|
|
491
|
+
"witan_code.github_app.installation_token",
|
|
492
|
+
lambda _creds, *, api_url: "ghs_fresh",
|
|
493
|
+
)
|
|
494
|
+
monkeypatch.setattr(
|
|
495
|
+
"witan_code.github_app.repo_is_private",
|
|
496
|
+
lambda _repo, token, *, api_url: token == "ghs_fresh",
|
|
497
|
+
)
|
|
498
|
+
assert main(["--visibility", "https://github.com/mitodl/x"]) == EXIT_OK
|
|
499
|
+
assert capsys.readouterr().out.strip() == "private"
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def test_main_visibility_reports_not_configured_without_an_app(monkeypatch):
|
|
503
|
+
"""No App means no private repo could have been cloned in the first place."""
|
|
504
|
+
for name in (APP_ID_ENV_VAR, INSTALLATION_ID_ENV_VAR, KEY_FILE_ENV_VAR):
|
|
505
|
+
monkeypatch.delenv(name, raising=False)
|
|
506
|
+
assert main(["--visibility", "https://github.com/mitodl/x"]) == EXIT_NOT_CONFIGURED
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def test_main_visibility_reports_a_lookup_failure(tmp_path, pem, monkeypatch, capsys):
|
|
510
|
+
for name, value in _env(tmp_path, pem).items():
|
|
511
|
+
monkeypatch.setenv(name, value)
|
|
512
|
+
monkeypatch.setenv(GH_TOKEN_ENV_VAR, "ghs_x")
|
|
513
|
+
|
|
514
|
+
def boom(_repo, _token, *, api_url):
|
|
515
|
+
raise GitHubAppError("GitHub said no")
|
|
516
|
+
|
|
517
|
+
monkeypatch.setattr("witan_code.github_app.repo_is_private", boom)
|
|
518
|
+
assert main(["--visibility", "https://github.com/mitodl/x"]) == EXIT_ERROR
|
|
519
|
+
assert "GitHub said no" in capsys.readouterr().err
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
@pytest.mark.parametrize("argv", [["--visibility"], ["--visibility", "a", "b"]])
|
|
523
|
+
def test_main_rejects_a_malformed_visibility_invocation(
|
|
524
|
+
argv, tmp_path, pem, monkeypatch
|
|
525
|
+
):
|
|
526
|
+
"""The usage error comes before any credential or network work."""
|
|
527
|
+
for name, value in _env(tmp_path, pem).items():
|
|
528
|
+
monkeypatch.setenv(name, value)
|
|
529
|
+
assert main(argv) == EXIT_ERROR
|
|
@@ -11,12 +11,30 @@ decision, not a plumbing one:
|
|
|
11
11
|
- A **user PAT** is one credential, but long-lived and scoped to everything its
|
|
12
12
|
owner can read — far more than the indexer needs, and rotation is manual.
|
|
13
13
|
- A **GitHub App** issues installation tokens that expire in an hour, and its
|
|
14
|
-
*installation* is
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
14
|
+
*installation* is the list of repositories it can reach — a list an org admin
|
|
15
|
+
manages in GitHub rather than one this deployment's config decides.
|
|
16
|
+
|
|
17
|
+
Hence the App. It needs ``contents: read`` to clone and ``metadata: read`` to
|
|
18
|
+
answer :func:`repo_is_private`; the installed App already holds both (verified
|
|
19
|
+
against ``GET /orgs/mitodl/installations``, 2026-08-28), so the visibility
|
|
20
|
+
guard needed no permission change.
|
|
21
|
+
|
|
22
|
+
★ THE INSTALLATION IS NOT THE CONTROL IT LOOKS LIKE. It is tempting to read
|
|
23
|
+
the bullet above as "a private repo cannot reach a shared graph by a Pulumi
|
|
24
|
+
change alone, because two independently-controlled things have to agree" —
|
|
25
|
+
this module said exactly that until 2026-08-28, and it is false as installed.
|
|
26
|
+
The same `GET /orgs/mitodl/installations` reports
|
|
27
|
+
``repository_selection: "all"``, so the App can already reach every private
|
|
28
|
+
repo in the org and adding one to ``managed_repos`` is the only step there is.
|
|
29
|
+
|
|
30
|
+
What actually stops it is the entrypoint's refusal
|
|
31
|
+
(``docker/witan-ci-index.sh``): every repo's visibility is checked before its
|
|
32
|
+
clone, private ones are refused, and ``WITAN_CODE_CI_ALLOW_PRIVATE_REPOS=1`` is
|
|
33
|
+
the explicit opt-in. That is a real second thing to agree, because it lives in
|
|
34
|
+
the deployment where a reviewer sees it. It is a stopgap for a missing read
|
|
35
|
+
control, not the control itself: a shared code graph grants ``read`` to every
|
|
36
|
+
actor holding a token, so a private repo indexed into one is readable by every
|
|
37
|
+
witan user. See ``docs/adr/0010-private-code-graph-read-scoping.md``.
|
|
20
38
|
|
|
21
39
|
WHY A TOKEN PER REPO, NOT PER SWEEP
|
|
22
40
|
|
|
@@ -50,6 +68,7 @@ import time
|
|
|
50
68
|
from dataclasses import dataclass
|
|
51
69
|
from pathlib import Path
|
|
52
70
|
from typing import TYPE_CHECKING
|
|
71
|
+
from urllib.parse import urlsplit
|
|
53
72
|
|
|
54
73
|
from witan_core.observability import get_logger
|
|
55
74
|
|
|
@@ -64,9 +83,12 @@ _MODULE = "witan_code.github_app"
|
|
|
64
83
|
__all__ = [
|
|
65
84
|
"AppCredentials",
|
|
66
85
|
"GitHubAppError",
|
|
86
|
+
"api_repo_host",
|
|
67
87
|
"app_jwt",
|
|
68
88
|
"from_env",
|
|
69
89
|
"installation_token",
|
|
90
|
+
"repo_is_private",
|
|
91
|
+
"repo_owner_name",
|
|
70
92
|
]
|
|
71
93
|
|
|
72
94
|
GITHUB_API_URL = "https://api.github.com"
|
|
@@ -82,8 +104,20 @@ _ENV_VARS = (APP_ID_ENV_VAR, INSTALLATION_ID_ENV_VAR, KEY_FILE_ENV_VAR)
|
|
|
82
104
|
# plumbing is the part most likely to be subtly wrong, and it cannot be tested
|
|
83
105
|
# at all if the API host is a constant. A GitHub Enterprise host would use it
|
|
84
106
|
# for real.
|
|
107
|
+
#
|
|
108
|
+
# Pointing this at a stub also moves the host that repo URIs must be on:
|
|
109
|
+
# `repo_owner_name` refuses a repo this API cannot answer for, so a stub on
|
|
110
|
+
# 127.0.0.1:8731 has to be swept with `https://127.0.0.1:8731/owner/name`
|
|
111
|
+
# URIs, not `https://github.com/...` ones. That is the guard working, not an
|
|
112
|
+
# obstacle to route around.
|
|
85
113
|
API_URL_ENV_VAR = "WITAN_CODE_GITHUB_API_URL"
|
|
86
114
|
|
|
115
|
+
# Where the entrypoint keeps the installation token it minted for the repo it
|
|
116
|
+
# is about to clone. Named here rather than only in the shell so the two agree:
|
|
117
|
+
# `--visibility` reuses that token when it is set, which is the difference
|
|
118
|
+
# between one API call per repo and three (a JWT exchange plus the lookup).
|
|
119
|
+
GH_TOKEN_ENV_VAR = "WITAN_CODE_GH_TOKEN"
|
|
120
|
+
|
|
87
121
|
# GitHub rejects an App JWT whose lifetime exceeds 10 minutes. Nine leaves room
|
|
88
122
|
# for the clock skew backdate below without crossing that line.
|
|
89
123
|
_JWT_LIFETIME_SECONDS = 9 * 60
|
|
@@ -93,13 +127,14 @@ _JWT_BACKDATE_SECONDS = 60
|
|
|
93
127
|
|
|
94
128
|
_HTTP_TIMEOUT = 15
|
|
95
129
|
_HTTP_CREATED = 201
|
|
130
|
+
_HTTP_OK = 200
|
|
96
131
|
# Enough of an error body to identify the failure, bounded so a stray HTML
|
|
97
132
|
# error page does not land in the job log in full.
|
|
98
133
|
_ERROR_BODY_LIMIT = 500
|
|
99
134
|
|
|
100
135
|
|
|
101
136
|
class GitHubAppError(RuntimeError):
|
|
102
|
-
"""Minting an installation token failed."""
|
|
137
|
+
"""Minting an installation token, or asking GitHub about a repo, failed."""
|
|
103
138
|
|
|
104
139
|
|
|
105
140
|
@dataclass(frozen=True)
|
|
@@ -261,6 +296,117 @@ def installation_token(
|
|
|
261
296
|
return token
|
|
262
297
|
|
|
263
298
|
|
|
299
|
+
def api_repo_host(api_url: str) -> str:
|
|
300
|
+
"""The repo host whose repositories ``api_url`` answers for.
|
|
301
|
+
|
|
302
|
+
``https://api.github.com`` serves ``github.com``; a GitHub Enterprise API
|
|
303
|
+
(``https://ghe.example/api/v3``) serves its own host. Used to refuse a repo
|
|
304
|
+
URI whose host this API cannot speak for — see :func:`repo_owner_name`.
|
|
305
|
+
"""
|
|
306
|
+
netloc = urlsplit(api_url.strip()).netloc.lower()
|
|
307
|
+
return netloc.removeprefix("api.") if netloc == "api.github.com" else netloc
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def repo_owner_name(repo: str, *, api_url: str = GITHUB_API_URL) -> tuple[str, str]:
|
|
311
|
+
"""``(owner, name)`` from a canonical repo URI, for the API path.
|
|
312
|
+
|
|
313
|
+
The sweep works in canonical URIs (``https://github.com/mitodl/agent-kit``)
|
|
314
|
+
because that is what witan-code detects from a checkout's remote and keys
|
|
315
|
+
graphs on. GitHub's API wants the two components separately.
|
|
316
|
+
|
|
317
|
+
★ THE URI IS VALIDATED, NOT JUST SPLIT, AND THAT IS THE POINT. The caller
|
|
318
|
+
is a guard that decides whether a repo may be cloned, and the *clone* uses
|
|
319
|
+
the whole URI while the *check* uses only these two components against
|
|
320
|
+
``api_url``. Anything that lets those two disagree lets the guard approve
|
|
321
|
+
one repository and the sweep fetch another: `https://other.example/o/r`
|
|
322
|
+
would be cleared by `github.com/o/r`'s visibility, and
|
|
323
|
+
`https://github.com/a/b/c/d` by `c/d`'s. So this requires an ``https`` URI
|
|
324
|
+
whose host is the one ``api_url`` answers for (:func:`api_repo_host`) and
|
|
325
|
+
whose path is exactly two components — refusing, never repairing, anything
|
|
326
|
+
else. A repo URI outside the configured host is not "the same repo
|
|
327
|
+
elsewhere"; it is a question this API cannot answer, and an unanswered
|
|
328
|
+
question must fail closed.
|
|
329
|
+
"""
|
|
330
|
+
split = urlsplit(repo.strip())
|
|
331
|
+
path = split.path.removesuffix(".git")
|
|
332
|
+
parts = [component for component in path.split("/") if component]
|
|
333
|
+
expected_host = api_repo_host(api_url)
|
|
334
|
+
if split.scheme != "https" or len(parts) != 2: # noqa: PLR2004 — owner and name
|
|
335
|
+
raise GitHubAppError(
|
|
336
|
+
f"Cannot read an owner and repository name out of {repo!r}; "
|
|
337
|
+
f"expected a canonical URI like https://{expected_host}/owner/name."
|
|
338
|
+
)
|
|
339
|
+
if split.netloc.lower() != expected_host:
|
|
340
|
+
raise GitHubAppError(
|
|
341
|
+
f"Refusing to check {repo!r} against {api_url}: that API answers "
|
|
342
|
+
f"for {expected_host!r}, so it would report some other "
|
|
343
|
+
f"repository's visibility while the clone used this URI. Point "
|
|
344
|
+
f"{API_URL_ENV_VAR} at the API for {split.netloc!r} if that is "
|
|
345
|
+
"the host meant."
|
|
346
|
+
)
|
|
347
|
+
return parts[0], parts[1]
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def repo_is_private(
|
|
351
|
+
repo: str,
|
|
352
|
+
token: str,
|
|
353
|
+
*,
|
|
354
|
+
client: httpx2.Client | None = None,
|
|
355
|
+
api_url: str = GITHUB_API_URL,
|
|
356
|
+
) -> bool:
|
|
357
|
+
"""Whether GitHub considers ``repo`` private, asked as the installation.
|
|
358
|
+
|
|
359
|
+
``private`` rather than ``visibility``: an ``internal`` repo (GitHub
|
|
360
|
+
Enterprise) is not public and reports ``private: true``, so the boolean is
|
|
361
|
+
the one that stays correct if this ever runs against an Enterprise host,
|
|
362
|
+
while a ``visibility != "public"`` test would have to grow a case.
|
|
363
|
+
|
|
364
|
+
A 404 raises rather than returning either answer. It means the
|
|
365
|
+
installation cannot see this repository at all — which is genuinely
|
|
366
|
+
ambiguous between "private and out of scope" and "does not exist" — and
|
|
367
|
+
the caller must not read an unanswered question as "public".
|
|
368
|
+
"""
|
|
369
|
+
import httpx2
|
|
370
|
+
|
|
371
|
+
owner, name = repo_owner_name(repo, api_url=api_url)
|
|
372
|
+
owns = client is None
|
|
373
|
+
http = client or httpx2.Client(timeout=_HTTP_TIMEOUT)
|
|
374
|
+
url = f"{api_url.rstrip('/')}/repos/{owner}/{name}"
|
|
375
|
+
try:
|
|
376
|
+
response = http.get(
|
|
377
|
+
url,
|
|
378
|
+
headers={
|
|
379
|
+
"Authorization": f"Bearer {token}",
|
|
380
|
+
"Accept": "application/vnd.github+json",
|
|
381
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
382
|
+
},
|
|
383
|
+
)
|
|
384
|
+
except httpx2.HTTPError as exc:
|
|
385
|
+
raise GitHubAppError(f"Could not reach {url}: {exc}") from exc
|
|
386
|
+
finally:
|
|
387
|
+
if owns:
|
|
388
|
+
http.close()
|
|
389
|
+
|
|
390
|
+
if response.status_code != _HTTP_OK:
|
|
391
|
+
raise GitHubAppError(
|
|
392
|
+
f"GitHub would not describe {owner}/{name} "
|
|
393
|
+
f"(HTTP {response.status_code}): {response.text[:_ERROR_BODY_LIMIT]}"
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
try:
|
|
397
|
+
private = response.json()["private"]
|
|
398
|
+
except (ValueError, KeyError, TypeError) as exc:
|
|
399
|
+
raise GitHubAppError(
|
|
400
|
+
f"GitHub returned no `private` field describing {owner}/{name}"
|
|
401
|
+
) from exc
|
|
402
|
+
if not isinstance(private, bool):
|
|
403
|
+
raise GitHubAppError(
|
|
404
|
+
f"GitHub returned a non-boolean `private` field for {owner}/{name}: "
|
|
405
|
+
f"{private!r}"
|
|
406
|
+
)
|
|
407
|
+
return private
|
|
408
|
+
|
|
409
|
+
|
|
264
410
|
# Exit codes, which the entrypoint branches on — see docker/witan-ci-index.sh.
|
|
265
411
|
EXIT_OK = 0
|
|
266
412
|
EXIT_ERROR = 1
|
|
@@ -268,28 +414,43 @@ EXIT_NOT_CONFIGURED = 2
|
|
|
268
414
|
|
|
269
415
|
|
|
270
416
|
def main(argv: list[str] | None = None) -> int:
|
|
271
|
-
"""``python -m witan_code.github_app [--check]``.
|
|
417
|
+
"""``python -m witan_code.github_app [--check | --visibility <repo-uri>]``.
|
|
272
418
|
|
|
273
419
|
Default: print a fresh installation token on stdout. ``--check`` resolves
|
|
274
420
|
the configuration and prints nothing, so a caller can decide once whether
|
|
275
421
|
to take the authenticated path without minting a token it will not use.
|
|
422
|
+
``--visibility`` prints ``public`` or ``private`` for one repo, which is
|
|
423
|
+
what the entrypoint's private-repo refusal is built on.
|
|
276
424
|
|
|
277
425
|
Exits ``EXIT_NOT_CONFIGURED`` when no App is configured — distinct from
|
|
278
426
|
``EXIT_ERROR`` because "this deployment has only public repos" and
|
|
279
427
|
"the credentials are broken" call for opposite responses from the caller.
|
|
428
|
+
``--visibility`` shares that exit code for the same reason it shares the
|
|
429
|
+
credential: with no App there is no private repo to refuse, because there
|
|
430
|
+
is nothing that could have cloned one.
|
|
280
431
|
"""
|
|
281
432
|
argv = sys.argv[1:] if argv is None else argv
|
|
282
433
|
check_only = argv == ["--check"]
|
|
283
|
-
if argv and
|
|
284
|
-
|
|
434
|
+
visibility_of = argv[1] if len(argv) == 2 and argv[0] == "--visibility" else None
|
|
435
|
+
if argv and not check_only and visibility_of is None:
|
|
436
|
+
print(
|
|
437
|
+
f"usage: python -m {_MODULE} [--check | --visibility <repo-uri>]",
|
|
438
|
+
file=sys.stderr,
|
|
439
|
+
)
|
|
285
440
|
return EXIT_ERROR
|
|
286
441
|
|
|
287
442
|
try:
|
|
288
443
|
credentials = from_env()
|
|
289
444
|
if credentials is None:
|
|
290
445
|
return EXIT_NOT_CONFIGURED
|
|
291
|
-
|
|
292
|
-
|
|
446
|
+
api_url = os.environ.get(API_URL_ENV_VAR) or GITHUB_API_URL
|
|
447
|
+
if visibility_of is not None:
|
|
448
|
+
token = os.environ.get(GH_TOKEN_ENV_VAR) or installation_token(
|
|
449
|
+
credentials, api_url=api_url
|
|
450
|
+
)
|
|
451
|
+
private = repo_is_private(visibility_of, token, api_url=api_url)
|
|
452
|
+
print("private" if private else "public")
|
|
453
|
+
elif not check_only:
|
|
293
454
|
print(installation_token(credentials, api_url=api_url))
|
|
294
455
|
except GitHubAppError as exc:
|
|
295
456
|
# Logged rather than printed: this runs inside the CI indexer CronJob,
|
|
@@ -74,8 +74,8 @@ mcp.add_middleware(elicit.MRTRElicitationMiddleware())
|
|
|
74
74
|
|
|
75
75
|
# The `io.modelcontextprotocol/tasks` extension (SEP-2663), which lets a client
|
|
76
76
|
# take a handle on a long `code_reindex` and poll it instead of holding a tool
|
|
77
|
-
# call open for minutes. Optional (`witan-code[tasks]`): it
|
|
78
|
-
#
|
|
77
|
+
# call open for minutes. Optional (`witan-code[tasks]`): it pulls a Docket/Redis
|
|
78
|
+
# stack that the default in-process `memory://` backend does not need. The
|
|
79
79
|
# flag has to gate the tool declaration too — a `task=True` tool refuses to
|
|
80
80
|
# serve at all when the extension is missing, rather than degrading.
|
|
81
81
|
#
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
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
|