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.
Files changed (102) hide show
  1. {witan_code-0.16.0 → witan_code-0.17.0}/CHANGELOG.md +11 -1
  2. {witan_code-0.16.0 → witan_code-0.17.0}/PKG-INFO +3 -3
  3. {witan_code-0.16.0 → witan_code-0.17.0}/pyproject.toml +6 -7
  4. {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_github_app.py +219 -0
  5. {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/github_app.py +173 -12
  6. {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/server.py +2 -2
  7. {witan_code-0.16.0 → witan_code-0.17.0}/.gitignore +0 -0
  8. {witan_code-0.16.0 → witan_code-0.17.0}/README.md +0 -0
  9. {witan_code-0.16.0 → witan_code-0.17.0}/config/claude.json +0 -0
  10. {witan_code-0.16.0 → witan_code-0.17.0}/config/copilot.json +0 -0
  11. {witan_code-0.16.0 → witan_code-0.17.0}/config/pi.json +0 -0
  12. {witan_code-0.16.0 → witan_code-0.17.0}/conftest.py +0 -0
  13. {witan_code-0.16.0 → witan_code-0.17.0}/docs/BRANCH_INDEXING.md +0 -0
  14. {witan_code-0.16.0 → witan_code-0.17.0}/docs/CLI_REFERENCE.md +0 -0
  15. {witan_code-0.16.0 → witan_code-0.17.0}/docs/EDGE_PRECISION_TIERS.md +0 -0
  16. {witan_code-0.16.0 → witan_code-0.17.0}/docs/PACKAGE_MAP.md +0 -0
  17. {witan_code-0.16.0 → witan_code-0.17.0}/docs/STAGE2_STITCHING.md +0 -0
  18. {witan_code-0.16.0 → witan_code-0.17.0}/docs/SYMBOL_FORMAT.md +0 -0
  19. {witan_code-0.16.0 → witan_code-0.17.0}/docs/SYMBOL_TABLE.md +0 -0
  20. {witan_code-0.16.0 → witan_code-0.17.0}/docs/USER_GUIDE.md +0 -0
  21. {witan_code-0.16.0 → witan_code-0.17.0}/install.sh +0 -0
  22. {witan_code-0.16.0 → witan_code-0.17.0}/tests/__init__.py +0 -0
  23. {witan_code-0.16.0 → witan_code-0.17.0}/tests/conftest.py +0 -0
  24. {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_batched_deletes.py +0 -0
  25. {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_branches.py +0 -0
  26. {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_bridge.py +0 -0
  27. {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_chunked_load.py +0 -0
  28. {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_cli_dispatch.py +0 -0
  29. {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_cli_hooks.py +0 -0
  30. {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_cli_output_format.py +0 -0
  31. {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_config.py +0 -0
  32. {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_config_targets.py +0 -0
  33. {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_context.py +0 -0
  34. {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_crossrepo.py +0 -0
  35. {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_edges.py +0 -0
  36. {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_elicit.py +0 -0
  37. {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_enrichment.py +0 -0
  38. {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_graph.py +0 -0
  39. {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_hooks.py +0 -0
  40. {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_identity.py +0 -0
  41. {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_index_failure_diagnostics.py +0 -0
  42. {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_indexer.py +0 -0
  43. {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_indexer_scope.py +0 -0
  44. {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_ingest.py +0 -0
  45. {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_languages.py +0 -0
  46. {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_maintenance.py +0 -0
  47. {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_package_map.py +0 -0
  48. {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_reaper.py +0 -0
  49. {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_remote_proxy.py +0 -0
  50. {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_remote_store.py +0 -0
  51. {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_repo.py +0 -0
  52. {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_server.py +0 -0
  53. {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_setup.py +0 -0
  54. {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_stitch.py +0 -0
  55. {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_store.py +0 -0
  56. {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_symbol_table.py +0 -0
  57. {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_tasks.py +0 -0
  58. {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_tool_schema.py +0 -0
  59. {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_views.py +0 -0
  60. {witan_code-0.16.0 → witan_code-0.17.0}/tests/test_visualize.py +0 -0
  61. {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/__init__.py +0 -0
  62. {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/__main__.py +0 -0
  63. {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/bridge.py +0 -0
  64. {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/bridge_extractors.py +0 -0
  65. {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/cli.py +0 -0
  66. {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/config.py +0 -0
  67. {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/context.py +0 -0
  68. {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/edges.py +0 -0
  69. {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/elicit.py +0 -0
  70. {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/extensions/pi/codegraph.ts +0 -0
  71. {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/graph.py +0 -0
  72. {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/hooks.py +0 -0
  73. {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/identity.py +0 -0
  74. {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/indexer.py +0 -0
  75. {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/ingest.py +0 -0
  76. {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/maintenance.py +0 -0
  77. {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/output.py +0 -0
  78. {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/package_map.py +0 -0
  79. {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/queries/bridge.gq +0 -0
  80. {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/queries/code_mutations.gq +0 -0
  81. {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/queries/code_read.gq +0 -0
  82. {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/queries/delete.gq +0 -0
  83. {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/queries_ts/bash.scm +0 -0
  84. {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/queries_ts/hcl.scm +0 -0
  85. {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/queries_ts/python.scm +0 -0
  86. {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/queries_ts/sql.scm +0 -0
  87. {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/queries_ts/typescript.scm +0 -0
  88. {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/queries_ts/yaml.scm +0 -0
  89. {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/reaper.py +0 -0
  90. {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/remote/__init__.py +0 -0
  91. {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/remote/oidc.py +0 -0
  92. {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/remote/proxy.py +0 -0
  93. {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/remote/store.py +0 -0
  94. {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/repo.py +0 -0
  95. {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/schema/bridge-schema.pg +0 -0
  96. {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/schema/code-schema.pg +0 -0
  97. {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/setup.py +0 -0
  98. {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/skills/witan-code/SKILL.md +0 -0
  99. {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/stitch.py +0 -0
  100. {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/store.py +0 -0
  101. {witan_code-0.16.0 → witan_code-0.17.0}/witan_code/views.py +0 -0
  102. {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
- ## [Unreleased]
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.16.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,>=3.4.2
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.0.0b1; extra == 'tasks'
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.16.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>=3.4.2,<5",
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 exists only for fastmcp 4.x while the pin above still spans 3.4.x,
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.0.0b1,<5"]
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.0.0b1,<5", "cryptography"]
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.16.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 itself the list of repositories it can reach. That list is
15
- managed by an org admin in GitHub rather than by this deployment's config, so
16
- a private repo's code cannot be pulled into a shared graph by a Pulumi change
17
- alone — two independently-controlled things have to agree first.
18
-
19
- Hence the App. The permission it needs is exactly ``contents: read``.
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 not check_only:
284
- print(f"usage: python -m {_MODULE} [--check]", file=sys.stderr)
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
- if not check_only:
292
- api_url = os.environ.get(API_URL_ENV_VAR) or GITHUB_API_URL
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 exists only for
78
- # fastmcp 4.x, and this package still supports the 3.4.x end of its pin. The
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