witan-code 0.16.0__tar.gz → 0.18.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 (105) hide show
  1. {witan_code-0.16.0 → witan_code-0.18.0}/CHANGELOG.md +57 -1
  2. {witan_code-0.16.0 → witan_code-0.18.0}/PKG-INFO +3 -3
  3. {witan_code-0.16.0 → witan_code-0.18.0}/pyproject.toml +6 -7
  4. witan_code-0.18.0/tests/test_cli_selected_target.py +86 -0
  5. {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_config_targets.py +75 -0
  6. {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_github_app.py +219 -0
  7. witan_code-0.18.0/tests/test_index_reports_its_store.py +122 -0
  8. {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_indexer.py +3 -0
  9. {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_remote_store.py +37 -0
  10. {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/cli.py +30 -9
  11. {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/github_app.py +173 -12
  12. {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/indexer.py +32 -4
  13. witan_code-0.18.0/witan_code/selected_target.py +28 -0
  14. {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/server.py +11 -4
  15. {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/store.py +22 -0
  16. {witan_code-0.16.0 → witan_code-0.18.0}/.gitignore +0 -0
  17. {witan_code-0.16.0 → witan_code-0.18.0}/README.md +0 -0
  18. {witan_code-0.16.0 → witan_code-0.18.0}/config/claude.json +0 -0
  19. {witan_code-0.16.0 → witan_code-0.18.0}/config/copilot.json +0 -0
  20. {witan_code-0.16.0 → witan_code-0.18.0}/config/pi.json +0 -0
  21. {witan_code-0.16.0 → witan_code-0.18.0}/conftest.py +0 -0
  22. {witan_code-0.16.0 → witan_code-0.18.0}/docs/BRANCH_INDEXING.md +0 -0
  23. {witan_code-0.16.0 → witan_code-0.18.0}/docs/CLI_REFERENCE.md +0 -0
  24. {witan_code-0.16.0 → witan_code-0.18.0}/docs/EDGE_PRECISION_TIERS.md +0 -0
  25. {witan_code-0.16.0 → witan_code-0.18.0}/docs/PACKAGE_MAP.md +0 -0
  26. {witan_code-0.16.0 → witan_code-0.18.0}/docs/STAGE2_STITCHING.md +0 -0
  27. {witan_code-0.16.0 → witan_code-0.18.0}/docs/SYMBOL_FORMAT.md +0 -0
  28. {witan_code-0.16.0 → witan_code-0.18.0}/docs/SYMBOL_TABLE.md +0 -0
  29. {witan_code-0.16.0 → witan_code-0.18.0}/docs/USER_GUIDE.md +0 -0
  30. {witan_code-0.16.0 → witan_code-0.18.0}/install.sh +0 -0
  31. {witan_code-0.16.0 → witan_code-0.18.0}/tests/__init__.py +0 -0
  32. {witan_code-0.16.0 → witan_code-0.18.0}/tests/conftest.py +0 -0
  33. {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_batched_deletes.py +0 -0
  34. {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_branches.py +0 -0
  35. {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_bridge.py +0 -0
  36. {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_chunked_load.py +0 -0
  37. {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_cli_dispatch.py +0 -0
  38. {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_cli_hooks.py +0 -0
  39. {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_cli_output_format.py +0 -0
  40. {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_config.py +0 -0
  41. {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_context.py +0 -0
  42. {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_crossrepo.py +0 -0
  43. {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_edges.py +0 -0
  44. {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_elicit.py +0 -0
  45. {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_enrichment.py +0 -0
  46. {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_graph.py +0 -0
  47. {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_hooks.py +0 -0
  48. {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_identity.py +0 -0
  49. {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_index_failure_diagnostics.py +0 -0
  50. {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_indexer_scope.py +0 -0
  51. {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_ingest.py +0 -0
  52. {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_languages.py +0 -0
  53. {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_maintenance.py +0 -0
  54. {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_package_map.py +0 -0
  55. {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_reaper.py +0 -0
  56. {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_remote_proxy.py +0 -0
  57. {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_repo.py +0 -0
  58. {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_server.py +0 -0
  59. {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_setup.py +0 -0
  60. {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_stitch.py +0 -0
  61. {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_store.py +0 -0
  62. {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_symbol_table.py +0 -0
  63. {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_tasks.py +0 -0
  64. {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_tool_schema.py +0 -0
  65. {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_views.py +0 -0
  66. {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_visualize.py +0 -0
  67. {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/__init__.py +0 -0
  68. {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/__main__.py +0 -0
  69. {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/bridge.py +0 -0
  70. {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/bridge_extractors.py +0 -0
  71. {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/config.py +0 -0
  72. {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/context.py +0 -0
  73. {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/edges.py +0 -0
  74. {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/elicit.py +0 -0
  75. {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/extensions/pi/codegraph.ts +0 -0
  76. {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/graph.py +0 -0
  77. {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/hooks.py +0 -0
  78. {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/identity.py +0 -0
  79. {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/ingest.py +0 -0
  80. {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/maintenance.py +0 -0
  81. {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/output.py +0 -0
  82. {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/package_map.py +0 -0
  83. {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/queries/bridge.gq +0 -0
  84. {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/queries/code_mutations.gq +0 -0
  85. {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/queries/code_read.gq +0 -0
  86. {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/queries/delete.gq +0 -0
  87. {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/queries_ts/bash.scm +0 -0
  88. {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/queries_ts/hcl.scm +0 -0
  89. {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/queries_ts/python.scm +0 -0
  90. {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/queries_ts/sql.scm +0 -0
  91. {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/queries_ts/typescript.scm +0 -0
  92. {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/queries_ts/yaml.scm +0 -0
  93. {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/reaper.py +0 -0
  94. {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/remote/__init__.py +0 -0
  95. {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/remote/oidc.py +0 -0
  96. {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/remote/proxy.py +0 -0
  97. {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/remote/store.py +0 -0
  98. {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/repo.py +0 -0
  99. {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/schema/bridge-schema.pg +0 -0
  100. {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/schema/code-schema.pg +0 -0
  101. {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/setup.py +0 -0
  102. {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/skills/witan-code/SKILL.md +0 -0
  103. {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/stitch.py +0 -0
  104. {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/views.py +0 -0
  105. {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/visualize.py +0 -0
@@ -6,7 +6,53 @@ 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.18.0] - 2026-09-02
10
+
11
+ ### Added
12
+
13
+ - **`witan --target <name> code index` reaches this package.** witan-council's
14
+ launcher gained an app-level `--target`, and `witan code …` mounts this
15
+ package's cyclopts App but *not* its meta launcher, so the value is forwarded
16
+ into `witan_code.selected_target` and read by `_srv()`. Before this the flag
17
+ was bound but never routed: `cli._srv()` called `load_remote_config()` with no
18
+ argument, so `witan --target ci code repos` answered from the ambient target
19
+ while appearing to work. Standalone `witan-code --target <name> …` works too.
20
+
21
+ - **`witan code index` prints the store it wrote to, unconditionally.** The one
22
+ command whose entire output is about where symbols went was silent about which
23
+ graph received them — and memory and code graphs are routed by separate
24
+ settings, so a target whose memory is deployed can still index onto the
25
+ laptop. Printed for local and shared alike: a line that appears only in the
26
+ bad case is one nobody has learned to look for. Carried on `IndexStats` rather
27
+ than recomputed by the printer, so a *failed* run reports the same
28
+ destination — the run where "which graph was this?" is actually open.
29
+
30
+ ### Fixed
31
+
32
+ - **`witan-code doctor` could not say which graph was broken under
33
+ `code_transport = "mcp"`.** The identifying column took `os.path.basename` of
34
+ the store address, which is right for a local path and wrong for an
35
+ MCP-routed one — those end in the endpoint URL, so all of production's
36
+ per-repo cluster graphs rendered as the identical string `mcp)`. `StoreRef`
37
+ gained a `label` that answers "which graph is this row" for all three address
38
+ forms, and `code_store_health` returns it alongside the full `store`.
39
+
40
+ - **A failed index did not always name its destination.** `IndexStats` was built
41
+ *after* `ensure_branch()` and the existing-hash read, both of which are remote
42
+ calls, so an unreachable endpoint or a rejected token raised before the stats
43
+ existed and the summary had nothing to print. Stats are now built straight
44
+ after the store resolves, and those two setup calls are wrapped so a failure
45
+ arrives as `IndexFailed` carrying the destination. `check_writable` is
46
+ deliberately left outside: it raises `SharedGraphWriteRefused`, a clean
47
+ self-explanatory refusal that callers assert on by type, not the unattributed
48
+ failure the phases exist to annotate.
49
+
50
+ - **`code_transport` is parsed off a target block.** A target registered with
51
+ `--remote-url` now carries `code_transport = "mcp"`, so branches indexed from
52
+ a checkout land on the cluster where another session — and another developer —
53
+ can see them, instead of in a directory on one laptop.
54
+
55
+ ## [0.17.0] - 2026-09-01
10
56
 
11
57
  ### Fixed
12
58
 
@@ -55,6 +101,16 @@ a MINOR bump may include breaking changes).
55
101
 
56
102
  ### Changed
57
103
 
104
+ - **Floor raised to `fastmcp>=4,<5`; the optional `tasks` extra moves to
105
+ `fastmcp-tasks>=4,<5`.** FastMCP 4.0 went GA, so the `fastmcp>=3.4.2,<5`
106
+ straddle is dropped, along with the `witan-code[tasks]`-only reason that
107
+ extra used to also need `--prerelease=allow` under uv. No behavior change on
108
+ this package's own code — witan-core's version-sniffing shims are what
109
+ actually moved (see its changelog); this is a dependency-floor release,
110
+ breaking only for a caller still resolving fastmcp 3.4.x, which pre-1.0
111
+ semver treats as a MINOR bump. Closes
112
+ `tk-move-the-fastmcp-floor-to-4-when-4-0-goes-ga-454f78`.
113
+
58
114
  - **`graph.check_writable` requires `actor`; `None` now means "no identity",
59
115
  full stop.** It used to fall back to `identity.actor_id()`, which gave the
60
116
  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.18.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.18.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.18.0"
209
208
  allow_dirty = true
210
209
 
211
210
  [[tool.bumpversion.files]]
@@ -0,0 +1,86 @@
1
+ """``witan code …`` dispatches to the target witan's launcher bound.
2
+
3
+ This package's App is mounted by witan's CLI but its meta launcher is not, so
4
+ witan's launcher forwards the ``--target`` it bound into
5
+ :mod:`witan_code.selected_target` (the same forwarding ``--output-format``
6
+ already gets, for the same reason). What is asserted here is the other end of
7
+ that wire: that the dispatch path actually consults it.
8
+
9
+ Resolving the ambient target here instead is not a cosmetic gap. ``witan code
10
+ index`` WRITES a code graph, so it would have written to whichever deployment
11
+ the checkout happened to match while the flag on the command line said
12
+ otherwise — and said nothing about it.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import pytest
18
+
19
+ from witan_code import cli as code_cli
20
+ from witan_code import config as code_cfg
21
+ from witan_code.selected_target import selected_target, set_selected_target
22
+
23
+
24
+ @pytest.fixture(autouse=True)
25
+ def _clean_selection():
26
+ """Module-level state, so a leaked value would silently steer a later test."""
27
+ set_selected_target(None)
28
+ yield
29
+ set_selected_target(None)
30
+
31
+
32
+ def test_dispatch_reads_the_forwarded_target(monkeypatch):
33
+ seen = {}
34
+
35
+ def _record(target=None):
36
+ seen["target"] = target
37
+ return None # in-process branch; a stub RemoteConfig is not the point
38
+
39
+ monkeypatch.setattr(code_cli, "_server", None)
40
+ monkeypatch.setattr(code_cfg, "load_remote_config", _record)
41
+ set_selected_target("qa")
42
+
43
+ code_cli._srv()
44
+
45
+ assert seen["target"] == "qa"
46
+
47
+
48
+ def test_no_forwarded_target_leaves_resolution_where_it_was(monkeypatch):
49
+ """``None`` is not "no target": the standalone ``witan-code`` launcher
50
+ declares no ``--target``, and resolution falls through to ``WITAN_TARGET``
51
+ and the checkout's ``match_*`` rules exactly as it always did."""
52
+ seen = {}
53
+
54
+ def _record(target=None):
55
+ seen["target"] = target
56
+ return None
57
+
58
+ monkeypatch.setattr(code_cli, "_server", None)
59
+ monkeypatch.setattr(code_cfg, "load_remote_config", _record)
60
+
61
+ code_cli._srv()
62
+
63
+ assert seen["target"] is None
64
+
65
+
66
+ def test_the_auth_path_reads_it_too(monkeypatch):
67
+ """``witan code login`` has to reach the same deployment the read commands
68
+ do, or it mints a token against one target and spends it on another."""
69
+ seen = {}
70
+
71
+ def _record(target=None):
72
+ seen["target"] = target
73
+ return None
74
+
75
+ monkeypatch.setattr(code_cfg, "load_remote_config", _record)
76
+ set_selected_target("ci")
77
+
78
+ with pytest.raises(SystemExit):
79
+ code_cli._remote_or_exit() # no remote configured -> clean exit
80
+
81
+ assert seen["target"] == "ci"
82
+
83
+
84
+ def test_accessor_round_trips():
85
+ set_selected_target("production")
86
+ assert selected_target() == "production"
@@ -377,3 +377,78 @@ def test_unknown_index_role_is_rejected(monkeypatch, toml_file):
377
377
 
378
378
  with pytest.raises(ValueError, match="Unknown index_role"):
379
379
  load()
380
+
381
+
382
+ # ── The seam with `witan target add` ─────────────────────────────────────────
383
+
384
+
385
+ def test_the_block_witan_target_add_writes_routes_code_graphs_to_the_cluster(
386
+ monkeypatch, toml_file
387
+ ):
388
+ """The exact block `witan target add --remote-url …` emits, read back here.
389
+
390
+ These are two packages agreeing by convention, not by a shared type: witan
391
+ (witan-council) renders the TOML, witan-code parses it, and nothing fails
392
+ loudly if they drift. Before `target add` learned `--code-transport`, a
393
+ deployed target had no `code_transport` key at all and fell through to the
394
+ `direct` default, leaving code graphs on the developer's own machine while
395
+ memory went to the deployment — silently, since a local store is a working
396
+ store. Keep this literal in step with `witan.cli.targets._FIELD_ORDER`.
397
+ """
398
+ monkeypatch.setenv(
399
+ "WITAN_CONFIG",
400
+ toml_file(
401
+ """
402
+ [targets.ol]
403
+ remote_url = "https://witan.ci.ol.mit.edu/mcp"
404
+ oidc_issuer = "https://sso-ci.ol.mit.edu/realms/ol-platform-engineering"
405
+ oidc_audience = "witan"
406
+ code_transport = "mcp"
407
+ match_orgs = ["mitodl"]
408
+ """
409
+ ),
410
+ )
411
+ monkeypatch.setenv("WITAN_REPO", "https://github.com/mitodl/agent-kit")
412
+ monkeypatch.delenv("WITAN_TARGET", raising=False)
413
+ monkeypatch.delenv("WITAN_CODE_TRANSPORT", raising=False)
414
+ monkeypatch.delenv("WITAN_CODE_SERVER", raising=False)
415
+
416
+ cfg = load()
417
+ assert cfg.target_name == "ol"
418
+ assert cfg.code_transport == "mcp"
419
+ # `mcp` alone makes it a cluster graph — code_server stays unset, because
420
+ # the deployment holds the address and resolves the actor from the JWT.
421
+ assert cfg.code_server is None
422
+ assert cfg.is_cluster
423
+
424
+
425
+ def test_a_deployed_target_without_code_transport_still_falls_back_to_local(
426
+ monkeypatch, toml_file
427
+ ):
428
+ """The pre-fix shape, pinned so the regression is legible rather than silent.
429
+
430
+ This is what every target registered before 2026-09-01 looks like. It is
431
+ not an error and never was — which is the whole problem, and why the
432
+ onboarding doc now tells people to re-run `target add --force`.
433
+ """
434
+ monkeypatch.setenv(
435
+ "WITAN_CONFIG",
436
+ toml_file(
437
+ """
438
+ [targets.ol]
439
+ remote_url = "https://witan.ci.ol.mit.edu/mcp"
440
+ oidc_issuer = "https://sso-ci.ol.mit.edu/realms/ol-platform-engineering"
441
+ match_orgs = ["mitodl"]
442
+ """
443
+ ),
444
+ )
445
+ monkeypatch.setenv("WITAN_REPO", "https://github.com/mitodl/agent-kit")
446
+ monkeypatch.delenv("WITAN_TARGET", raising=False)
447
+ monkeypatch.delenv("WITAN_CODE_TRANSPORT", raising=False)
448
+ monkeypatch.delenv("WITAN_CODE_SERVER", raising=False)
449
+
450
+ cfg = load()
451
+ assert cfg.code_transport == "direct"
452
+ assert not cfg.is_cluster
453
+ # Memory is deployed while code graphs are not — the split this task fixed.
454
+ assert load_remote_config() is not None
@@ -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
@@ -0,0 +1,122 @@
1
+ """`witan code index` has to say which graph it wrote.
2
+
3
+ Memory and code graphs are routed by SEPARATE settings, so a target whose
4
+ memory is deployed can still index onto the laptop — three non-maintainers were
5
+ in exactly that state on production. The one command whose entire output is
6
+ about where symbols went was silent about which store received them.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+
13
+ import pytest
14
+
15
+ from witan_code import cli as cli_module
16
+ from witan_code import indexer
17
+
18
+
19
+ def test_a_successful_index_names_the_store(monkeypatch, capsys):
20
+ stats = indexer.IndexStats(
21
+ scanned=12,
22
+ indexed=12,
23
+ symbols=88,
24
+ store="/home/dev/.local/share/witan-code/x.omni",
25
+ )
26
+ monkeypatch.setattr(indexer, "index_path", lambda path, *, force=False, **k: stats)
27
+
28
+ cli_module.index(Path("."))
29
+
30
+ assert "/home/dev/.local/share/witan-code/x.omni" in capsys.readouterr().out
31
+
32
+
33
+ def test_a_shared_store_is_reported_the_same_way(monkeypatch, capsys):
34
+ """Printed for local and shared alike. A line that appears only in the bad
35
+ case is a line nobody has learned to look for — and reading it is how "where
36
+ did this go" gets answered without knowing the setting exists."""
37
+ stats = indexer.IndexStats(
38
+ scanned=12, store="github.com/mitodl/agent-kit (via https://witan.example/mcp)"
39
+ )
40
+ monkeypatch.setattr(indexer, "index_path", lambda path, *, force=False, **k: stats)
41
+
42
+ cli_module.index(Path("."))
43
+
44
+ assert "https://witan.example/mcp" in capsys.readouterr().out
45
+
46
+
47
+ def test_the_branch_view_is_named_when_there_is_one(monkeypatch, capsys):
48
+ """On a shared graph the view is per writer per git branch, so the store
49
+ alone does not identify what a reader has to look at."""
50
+ stats = indexer.IndexStats(store="s", branch="act-0eb-feature-x")
51
+ monkeypatch.setattr(indexer, "index_path", lambda path, *, force=False, **k: stats)
52
+
53
+ cli_module.index(Path("."))
54
+
55
+ assert "branch=act-0eb-feature-x" in capsys.readouterr().out
56
+
57
+
58
+ def test_the_default_branch_prints_no_branch_field(monkeypatch, capsys):
59
+ stats = indexer.IndexStats(store="s")
60
+ monkeypatch.setattr(indexer, "index_path", lambda path, *, force=False, **k: stats)
61
+
62
+ cli_module.index(Path("."))
63
+
64
+ assert "branch=" not in capsys.readouterr().out
65
+
66
+
67
+ def test_a_failed_index_still_names_the_store(monkeypatch, capsys):
68
+ """The failing run is the one where "which graph was this?" is the open
69
+ question, so the destination is carried on the stats rather than recomputed
70
+ by the printer."""
71
+ stats = indexer.IndexStats(scanned=657, store="https://witan.example/mcp")
72
+
73
+ def failing_index(path, *, force=False, **kwargs):
74
+ raise indexer.IndexFailed(
75
+ "load of nodes and edges",
76
+ RuntimeError("timed out"),
77
+ stats=stats,
78
+ elapsed=181.0,
79
+ detail={"records": 9000},
80
+ )
81
+
82
+ monkeypatch.setattr(indexer, "index_path", failing_index)
83
+
84
+ with pytest.raises(indexer.IndexFailed):
85
+ cli_module.index(Path("."))
86
+
87
+ assert "https://witan.example/mcp" in capsys.readouterr().out
88
+
89
+
90
+ def test_a_setup_failure_names_the_store_it_was_reaching(tmp_path, monkeypatch):
91
+ """Regression, PR #316 review: the guarantee has to cover setup, not just
92
+ the load.
93
+
94
+ `ensure_branch` and the existing-hash read are REMOTE calls, and `IndexStats`
95
+ used to be built after both. So an unreachable endpoint or a rejected token
96
+ raised before the stats object existed, `_index` had nothing to print, and
97
+ the run that most needed a destination reported none — the exact gap the
98
+ "a failed run still identifies its graph" guarantee was added to close.
99
+ """
100
+ import subprocess
101
+
102
+ from witan_code import config as cfg_module
103
+ from witan_code.graph import OmnigraphClient
104
+
105
+ monkeypatch.setenv("WITAN_REPO", "https://github.com/test/cg")
106
+ monkeypatch.setenv("WITAN_CODE_DIR", str(tmp_path / "code"))
107
+ src = tmp_path / "repo"
108
+ src.mkdir()
109
+ subprocess.run(["git", "init", "-q", "-b", "main"], cwd=src, check=True)
110
+ (src / "a.py").write_text("def a():\n return 1\n")
111
+
112
+ def _unreachable(self, *a, **k):
113
+ raise RuntimeError("connection refused")
114
+
115
+ monkeypatch.setattr(OmnigraphClient, "ensure_branch", _unreachable)
116
+
117
+ with pytest.raises(indexer.IndexFailed) as excinfo:
118
+ indexer.index_path(src, config=cfg_module.load())
119
+
120
+ assert excinfo.value.phase == "branch setup"
121
+ assert excinfo.value.stats.store # the destination, not an empty string
122
+ assert "cg" in excinfo.value.stats.store
@@ -21,6 +21,9 @@ def test_index_extracts_symbols_and_edges(sample_repo):
21
21
 
22
22
  slug = repo_mod.detect()
23
23
  store = store_mod.store_for_repo(slug, cfg)
24
+ # The destination the summary reports comes off the run itself, so this is
25
+ # what stops that line drifting from where the rows actually landed.
26
+ assert stats.store == str(store)
24
27
  client = OmnigraphClient(str(store), cfg.queries_dir)
25
28
 
26
29
  runs = client.read("code_read.gq", "find_by_name", {"name": "run"})