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.
- {witan_code-0.16.0 → witan_code-0.18.0}/CHANGELOG.md +57 -1
- {witan_code-0.16.0 → witan_code-0.18.0}/PKG-INFO +3 -3
- {witan_code-0.16.0 → witan_code-0.18.0}/pyproject.toml +6 -7
- witan_code-0.18.0/tests/test_cli_selected_target.py +86 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_config_targets.py +75 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_github_app.py +219 -0
- witan_code-0.18.0/tests/test_index_reports_its_store.py +122 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_indexer.py +3 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_remote_store.py +37 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/cli.py +30 -9
- {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/github_app.py +173 -12
- {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/indexer.py +32 -4
- witan_code-0.18.0/witan_code/selected_target.py +28 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/server.py +11 -4
- {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/store.py +22 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/.gitignore +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/README.md +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/config/claude.json +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/config/copilot.json +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/config/pi.json +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/conftest.py +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/docs/BRANCH_INDEXING.md +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/docs/CLI_REFERENCE.md +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/docs/EDGE_PRECISION_TIERS.md +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/docs/PACKAGE_MAP.md +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/docs/STAGE2_STITCHING.md +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/docs/SYMBOL_FORMAT.md +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/docs/SYMBOL_TABLE.md +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/docs/USER_GUIDE.md +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/install.sh +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/tests/__init__.py +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/tests/conftest.py +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_batched_deletes.py +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_branches.py +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_bridge.py +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_chunked_load.py +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_cli_dispatch.py +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_cli_hooks.py +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_cli_output_format.py +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_config.py +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_context.py +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_crossrepo.py +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_edges.py +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_elicit.py +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_enrichment.py +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_graph.py +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_hooks.py +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_identity.py +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_index_failure_diagnostics.py +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_indexer_scope.py +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_ingest.py +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_languages.py +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_maintenance.py +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_package_map.py +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_reaper.py +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_remote_proxy.py +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_repo.py +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_server.py +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_setup.py +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_stitch.py +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_store.py +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_symbol_table.py +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_tasks.py +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_tool_schema.py +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_views.py +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/tests/test_visualize.py +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/__init__.py +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/__main__.py +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/bridge.py +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/bridge_extractors.py +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/config.py +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/context.py +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/edges.py +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/elicit.py +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/extensions/pi/codegraph.ts +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/graph.py +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/hooks.py +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/identity.py +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/ingest.py +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/maintenance.py +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/output.py +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/package_map.py +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/queries/bridge.gq +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/queries/code_mutations.gq +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/queries/code_read.gq +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/queries/delete.gq +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/queries_ts/bash.scm +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/queries_ts/hcl.scm +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/queries_ts/python.scm +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/queries_ts/sql.scm +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/queries_ts/typescript.scm +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/queries_ts/yaml.scm +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/reaper.py +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/remote/__init__.py +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/remote/oidc.py +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/remote/proxy.py +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/remote/store.py +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/repo.py +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/schema/bridge-schema.pg +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/schema/code-schema.pg +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/setup.py +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/skills/witan-code/SKILL.md +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/stitch.py +0 -0
- {witan_code-0.16.0 → witan_code-0.18.0}/witan_code/views.py +0 -0
- {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
|
-
## [
|
|
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.
|
|
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,>=
|
|
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.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>=
|
|
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.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"})
|