witan-code 0.4.0__tar.gz → 0.6.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.4.0 → witan_code-0.6.0}/CHANGELOG.md +37 -0
- {witan_code-0.4.0 → witan_code-0.6.0}/PKG-INFO +25 -1
- {witan_code-0.4.0 → witan_code-0.6.0}/README.md +23 -0
- {witan_code-0.4.0 → witan_code-0.6.0}/pyproject.toml +7 -4
- witan_code-0.6.0/tests/test_config.py +64 -0
- witan_code-0.6.0/tests/test_config_targets.py +247 -0
- {witan_code-0.4.0 → witan_code-0.6.0}/tests/test_repo.py +36 -0
- witan_code-0.6.0/witan_code/config.py +214 -0
- {witan_code-0.4.0 → witan_code-0.6.0}/witan_code/elicit.py +1 -1
- {witan_code-0.4.0 → witan_code-0.6.0}/witan_code/graph.py +2 -1
- {witan_code-0.4.0 → witan_code-0.6.0}/witan_code/repo.py +11 -5
- {witan_code-0.4.0 → witan_code-0.6.0}/witan_code/schema/bridge-schema.pg +6 -2
- {witan_code-0.4.0 → witan_code-0.6.0}/witan_code/schema/code-schema.pg +6 -1
- {witan_code-0.4.0 → witan_code-0.6.0}/witan_code/server.py +3 -0
- witan_code-0.4.0/witan_code/config.py +0 -65
- {witan_code-0.4.0 → witan_code-0.6.0}/.gitignore +0 -0
- {witan_code-0.4.0 → witan_code-0.6.0}/config/claude.json +0 -0
- {witan_code-0.4.0 → witan_code-0.6.0}/config/copilot.json +0 -0
- {witan_code-0.4.0 → witan_code-0.6.0}/config/pi.json +0 -0
- {witan_code-0.4.0 → witan_code-0.6.0}/docs/BRANCH_INDEXING.md +0 -0
- {witan_code-0.4.0 → witan_code-0.6.0}/docs/CLI_REFERENCE.md +0 -0
- {witan_code-0.4.0 → witan_code-0.6.0}/docs/EDGE_PRECISION_TIERS.md +0 -0
- {witan_code-0.4.0 → witan_code-0.6.0}/docs/PACKAGE_MAP.md +0 -0
- {witan_code-0.4.0 → witan_code-0.6.0}/docs/STAGE2_STITCHING.md +0 -0
- {witan_code-0.4.0 → witan_code-0.6.0}/docs/SYMBOL_FORMAT.md +0 -0
- {witan_code-0.4.0 → witan_code-0.6.0}/docs/SYMBOL_TABLE.md +0 -0
- {witan_code-0.4.0 → witan_code-0.6.0}/docs/USER_GUIDE.md +0 -0
- {witan_code-0.4.0 → witan_code-0.6.0}/install.sh +0 -0
- {witan_code-0.4.0 → witan_code-0.6.0}/tests/__init__.py +0 -0
- {witan_code-0.4.0 → witan_code-0.6.0}/tests/conftest.py +0 -0
- {witan_code-0.4.0 → witan_code-0.6.0}/tests/test_branches.py +0 -0
- {witan_code-0.4.0 → witan_code-0.6.0}/tests/test_bridge.py +0 -0
- {witan_code-0.4.0 → witan_code-0.6.0}/tests/test_cli_hooks.py +0 -0
- {witan_code-0.4.0 → witan_code-0.6.0}/tests/test_cli_output_format.py +0 -0
- {witan_code-0.4.0 → witan_code-0.6.0}/tests/test_context.py +0 -0
- {witan_code-0.4.0 → witan_code-0.6.0}/tests/test_crossrepo.py +0 -0
- {witan_code-0.4.0 → witan_code-0.6.0}/tests/test_edges.py +0 -0
- {witan_code-0.4.0 → witan_code-0.6.0}/tests/test_elicit.py +0 -0
- {witan_code-0.4.0 → witan_code-0.6.0}/tests/test_enrichment.py +0 -0
- {witan_code-0.4.0 → witan_code-0.6.0}/tests/test_graph.py +0 -0
- {witan_code-0.4.0 → witan_code-0.6.0}/tests/test_hooks.py +0 -0
- {witan_code-0.4.0 → witan_code-0.6.0}/tests/test_indexer.py +0 -0
- {witan_code-0.4.0 → witan_code-0.6.0}/tests/test_languages.py +0 -0
- {witan_code-0.4.0 → witan_code-0.6.0}/tests/test_maintenance.py +0 -0
- {witan_code-0.4.0 → witan_code-0.6.0}/tests/test_package_map.py +0 -0
- {witan_code-0.4.0 → witan_code-0.6.0}/tests/test_server.py +0 -0
- {witan_code-0.4.0 → witan_code-0.6.0}/tests/test_setup.py +0 -0
- {witan_code-0.4.0 → witan_code-0.6.0}/tests/test_stitch.py +0 -0
- {witan_code-0.4.0 → witan_code-0.6.0}/tests/test_symbol_table.py +0 -0
- {witan_code-0.4.0 → witan_code-0.6.0}/tests/test_visualize.py +0 -0
- {witan_code-0.4.0 → witan_code-0.6.0}/uv.lock +0 -0
- {witan_code-0.4.0 → witan_code-0.6.0}/witan_code/__init__.py +0 -0
- {witan_code-0.4.0 → witan_code-0.6.0}/witan_code/__main__.py +0 -0
- {witan_code-0.4.0 → witan_code-0.6.0}/witan_code/bridge.py +0 -0
- {witan_code-0.4.0 → witan_code-0.6.0}/witan_code/bridge_extractors.py +0 -0
- {witan_code-0.4.0 → witan_code-0.6.0}/witan_code/cli.py +0 -0
- {witan_code-0.4.0 → witan_code-0.6.0}/witan_code/context.py +0 -0
- {witan_code-0.4.0 → witan_code-0.6.0}/witan_code/edges.py +0 -0
- {witan_code-0.4.0 → witan_code-0.6.0}/witan_code/extensions/pi/codegraph.ts +0 -0
- {witan_code-0.4.0 → witan_code-0.6.0}/witan_code/hooks.py +0 -0
- {witan_code-0.4.0 → witan_code-0.6.0}/witan_code/indexer.py +0 -0
- {witan_code-0.4.0 → witan_code-0.6.0}/witan_code/maintenance.py +0 -0
- {witan_code-0.4.0 → witan_code-0.6.0}/witan_code/output.py +0 -0
- {witan_code-0.4.0 → witan_code-0.6.0}/witan_code/package_map.py +0 -0
- {witan_code-0.4.0 → witan_code-0.6.0}/witan_code/queries/bridge.gq +0 -0
- {witan_code-0.4.0 → witan_code-0.6.0}/witan_code/queries/code_mutations.gq +0 -0
- {witan_code-0.4.0 → witan_code-0.6.0}/witan_code/queries/code_read.gq +0 -0
- {witan_code-0.4.0 → witan_code-0.6.0}/witan_code/queries/delete.gq +0 -0
- {witan_code-0.4.0 → witan_code-0.6.0}/witan_code/queries_ts/bash.scm +0 -0
- {witan_code-0.4.0 → witan_code-0.6.0}/witan_code/queries_ts/hcl.scm +0 -0
- {witan_code-0.4.0 → witan_code-0.6.0}/witan_code/queries_ts/python.scm +0 -0
- {witan_code-0.4.0 → witan_code-0.6.0}/witan_code/queries_ts/sql.scm +0 -0
- {witan_code-0.4.0 → witan_code-0.6.0}/witan_code/queries_ts/typescript.scm +0 -0
- {witan_code-0.4.0 → witan_code-0.6.0}/witan_code/queries_ts/yaml.scm +0 -0
- {witan_code-0.4.0 → witan_code-0.6.0}/witan_code/setup.py +0 -0
- {witan_code-0.4.0 → witan_code-0.6.0}/witan_code/skills/witan-code/SKILL.md +0 -0
- {witan_code-0.4.0 → witan_code-0.6.0}/witan_code/stitch.py +0 -0
- {witan_code-0.4.0 → witan_code-0.6.0}/witan_code/store.py +0 -0
- {witan_code-0.4.0 → witan_code-0.6.0}/witan_code/visualize.py +0 -0
|
@@ -6,6 +6,43 @@ 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
|
+
## [0.6.0] - 2026-07-21
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- **`repo.detect(override=...)` and the `WITAN_REPO` env var now route
|
|
14
|
+
through `normalise`** (issue #142), same as an auto-detected git remote —
|
|
15
|
+
matching the fix in witan-council 0.6.0. Previously an explicitly-passed
|
|
16
|
+
`repo=` (or `WITAN_REPO`) was stored verbatim, bypassing
|
|
17
|
+
canonicalization. `graph_id` (the shared-cluster graph id) already
|
|
18
|
+
case-folded unconditionally and needs no change; a repo whose canonical
|
|
19
|
+
key changes case after this fix should be re-indexed (`witan-code
|
|
20
|
+
reindex`) so its per-repo store lands under the new key.
|
|
21
|
+
- Depends on `witan-core[cli]>=0.2,<1` (unchanged range; picks up 0.4.0's
|
|
22
|
+
repo-key case-fold in `normalise`, which this package's `repo.detect`
|
|
23
|
+
relies on).
|
|
24
|
+
|
|
25
|
+
## [0.5.0] - 2026-07-20
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
|
|
29
|
+
- `config.toml` + `[targets.<name>]` support (previously env-var only):
|
|
30
|
+
`WITAN_CONFIG` / `~/.config/witan/config.toml`, a global `code_dir`/
|
|
31
|
+
`author`, and named targets overriding `code_dir`/`author`, selected by
|
|
32
|
+
`WITAN_TARGET` env var, an explicit `load(target=...)`, or auto-detection
|
|
33
|
+
via `match_paths`/`match_repos`/`match_hosts`/`match_orgs` (same
|
|
34
|
+
precedence and file as witan — see `witan_core.target_config` and
|
|
35
|
+
`witan.config.load()`'s docstring). A target can carry witan's
|
|
36
|
+
`server`/`graph`/`token` alongside this server's `code_dir` under one
|
|
37
|
+
name; witan-code reads only the fields it knows. `Config` gained
|
|
38
|
+
`target_name`. `load()` now optionally takes a `target` argument (was
|
|
39
|
+
zero-argument only). New dependency: `pydantic>=2,<3`.
|
|
40
|
+
|
|
41
|
+
### Changed
|
|
42
|
+
|
|
43
|
+
- Now depends on `witan-core[cli]>=0.2,<1` (unchanged range; picks up
|
|
44
|
+
0.3.0, which adds the shared target-routing logic this release uses).
|
|
45
|
+
|
|
9
46
|
## [0.4.0] - 2026-07-16
|
|
10
47
|
|
|
11
48
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: witan-code
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.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
|
|
@@ -13,6 +13,7 @@ Requires-Python: >=3.11
|
|
|
13
13
|
Requires-Dist: agent-config-kit<1,>=0.4
|
|
14
14
|
Requires-Dist: cyclopts<5,>=4
|
|
15
15
|
Requires-Dist: fastmcp<4,>=3.4.2
|
|
16
|
+
Requires-Dist: pydantic<3,>=2
|
|
16
17
|
Requires-Dist: pyyaml<7,>=6
|
|
17
18
|
Requires-Dist: rich>=13
|
|
18
19
|
Requires-Dist: tomli-w<2,>=1
|
|
@@ -334,12 +335,35 @@ symlink alternative:
|
|
|
334
335
|
| `WITAN_AUTHOR` / `USER` | `unknown` | attribution string |
|
|
335
336
|
| `WITAN_REPO` | — | override the detected repo slug |
|
|
336
337
|
| `WITAN_CODE_OPTIMIZE_INTERVAL` | `86400` (daily) | throttle window (seconds) for `checkpoint`'s opportunistic store compaction; `0` disables it |
|
|
338
|
+
| `WITAN_CONFIG` | `~/.config/witan/config.toml` | config file path (see below) |
|
|
339
|
+
| `WITAN_TARGET` | — | force a named `[targets.<name>]` block instead of auto-detecting one |
|
|
337
340
|
|
|
338
341
|
The store URI for a repo is `<dir>/<sanitized-slug>.omni`, where the slug's `/`
|
|
339
342
|
and `:` are replaced with `_`. The shared cross-repo bridge lives alongside them
|
|
340
343
|
at `<dir>/_bridge.omni` and is created lazily on the first index that yields any
|
|
341
344
|
bindings.
|
|
342
345
|
|
|
346
|
+
### config.toml
|
|
347
|
+
|
|
348
|
+
witan-code reads the same `config.toml` as witan (witan-council) — a global
|
|
349
|
+
`code_dir`/`author`, plus named `[targets.<name>]` blocks that override them
|
|
350
|
+
and are selected by `WITAN_TARGET`, `load(target=...)` (Python API only — no
|
|
351
|
+
CLI `--target` flag yet), or auto-detection against the current
|
|
352
|
+
repo/checkout (`match_paths` > `match_repos` > `match_hosts` > `match_orgs`
|
|
353
|
+
— see witan's README/`witan/config.py` docstring for the full precedence).
|
|
354
|
+
Because the file is shared, one target block can carry witan's
|
|
355
|
+
`server`/`graph`/`token` alongside `code_dir` under
|
|
356
|
+
the same name — each server reads only the fields it knows:
|
|
357
|
+
|
|
358
|
+
```toml
|
|
359
|
+
[targets.work]
|
|
360
|
+
server = "http://witan.internal:8080" # witan (witan-council)
|
|
361
|
+
code_dir = "/mnt/work/witan-code" # witan-code
|
|
362
|
+
match_orgs = ["myorg"]
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
Environment variables always win over `config.toml`.
|
|
366
|
+
|
|
343
367
|
## MCP tools
|
|
344
368
|
|
|
345
369
|
Each tool resolves the per-repo store from the current working directory and
|
|
@@ -305,12 +305,35 @@ symlink alternative:
|
|
|
305
305
|
| `WITAN_AUTHOR` / `USER` | `unknown` | attribution string |
|
|
306
306
|
| `WITAN_REPO` | — | override the detected repo slug |
|
|
307
307
|
| `WITAN_CODE_OPTIMIZE_INTERVAL` | `86400` (daily) | throttle window (seconds) for `checkpoint`'s opportunistic store compaction; `0` disables it |
|
|
308
|
+
| `WITAN_CONFIG` | `~/.config/witan/config.toml` | config file path (see below) |
|
|
309
|
+
| `WITAN_TARGET` | — | force a named `[targets.<name>]` block instead of auto-detecting one |
|
|
308
310
|
|
|
309
311
|
The store URI for a repo is `<dir>/<sanitized-slug>.omni`, where the slug's `/`
|
|
310
312
|
and `:` are replaced with `_`. The shared cross-repo bridge lives alongside them
|
|
311
313
|
at `<dir>/_bridge.omni` and is created lazily on the first index that yields any
|
|
312
314
|
bindings.
|
|
313
315
|
|
|
316
|
+
### config.toml
|
|
317
|
+
|
|
318
|
+
witan-code reads the same `config.toml` as witan (witan-council) — a global
|
|
319
|
+
`code_dir`/`author`, plus named `[targets.<name>]` blocks that override them
|
|
320
|
+
and are selected by `WITAN_TARGET`, `load(target=...)` (Python API only — no
|
|
321
|
+
CLI `--target` flag yet), or auto-detection against the current
|
|
322
|
+
repo/checkout (`match_paths` > `match_repos` > `match_hosts` > `match_orgs`
|
|
323
|
+
— see witan's README/`witan/config.py` docstring for the full precedence).
|
|
324
|
+
Because the file is shared, one target block can carry witan's
|
|
325
|
+
`server`/`graph`/`token` alongside `code_dir` under
|
|
326
|
+
the same name — each server reads only the fields it knows:
|
|
327
|
+
|
|
328
|
+
```toml
|
|
329
|
+
[targets.work]
|
|
330
|
+
server = "http://witan.internal:8080" # witan (witan-council)
|
|
331
|
+
code_dir = "/mnt/work/witan-code" # witan-code
|
|
332
|
+
match_orgs = ["myorg"]
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
Environment variables always win over `config.toml`.
|
|
336
|
+
|
|
314
337
|
## MCP tools
|
|
315
338
|
|
|
316
339
|
Each tool resolves the per-repo store from the current working directory and
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "witan-code"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.6.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"
|
|
@@ -14,6 +14,9 @@ dependencies = [
|
|
|
14
14
|
"witan-core[cli]>=0.2,<1",
|
|
15
15
|
"fastmcp>=3.4.2,<4",
|
|
16
16
|
"cyclopts>=4,<5",
|
|
17
|
+
# [targets.<name>] override models (config.py) — also transitively pulled
|
|
18
|
+
# in by fastmcp, declared explicitly since config.py imports it directly.
|
|
19
|
+
"pydantic>=2,<3",
|
|
17
20
|
# Pinned to 0.25.x: tree-sitter 0.26.0 has a use-after-free in its pyo3 Node
|
|
18
21
|
# binding that segfaults (exit 139) under the heavy .parent()/.children()
|
|
19
22
|
# churn _walk_defs does on real-world-sized files — deterministically on the
|
|
@@ -58,8 +61,8 @@ build-backend = "hatchling.build"
|
|
|
58
61
|
# agent-config-kit version bumped alongside it — even before that version
|
|
59
62
|
# has actually landed on PyPI. Published wheels only see the PyPI-range
|
|
60
63
|
# dependency declared above.
|
|
61
|
-
agent-config-kit = {
|
|
62
|
-
witan-core = {
|
|
64
|
+
agent-config-kit = { workspace = true }
|
|
65
|
+
witan-core = { workspace = true }
|
|
63
66
|
|
|
64
67
|
[dependency-groups]
|
|
65
68
|
test = ["pytest>=8"]
|
|
@@ -72,7 +75,7 @@ testpaths = ["tests"]
|
|
|
72
75
|
packages = ["witan_code"]
|
|
73
76
|
|
|
74
77
|
[tool.bumpversion]
|
|
75
|
-
current_version = "0.
|
|
78
|
+
current_version = "0.6.0"
|
|
76
79
|
allow_dirty = true
|
|
77
80
|
|
|
78
81
|
[[tool.bumpversion.files]]
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Canonical cluster graph-id derivation (config.graph_id).
|
|
2
|
+
|
|
3
|
+
This id is a cross-repo contract: witan-code addresses `--graph <id>` and
|
|
4
|
+
ol-infrastructure's cluster.yaml provisioning declares the same `<id>`. If this
|
|
5
|
+
algorithm drifts from the provisioning side, clients address graphs the cluster
|
|
6
|
+
never created — so pin the observable mapping here.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from witan_code import config
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_graph_id_canonical_example():
|
|
13
|
+
# The example from the deployment decision; must be byte-for-byte stable.
|
|
14
|
+
assert (
|
|
15
|
+
config.graph_id("https://github.com/mitodl/ol-django")
|
|
16
|
+
== "code-github-com-mitodl-ol-django"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_graph_id_matches_constraint():
|
|
21
|
+
for repo in (
|
|
22
|
+
"https://github.com/mitodl/ol-django",
|
|
23
|
+
"http://example.com/x",
|
|
24
|
+
"git@github.com:mitodl/agent-kit.git",
|
|
25
|
+
"github.com/mitodl/ol-infrastructure",
|
|
26
|
+
"https://github.com/MITODL/Mixed_Case.Repo",
|
|
27
|
+
):
|
|
28
|
+
assert config.GRAPH_ID_RE.match(config.graph_id(repo)), repo
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_graph_id_no_underscores_and_lowercase():
|
|
32
|
+
gid = config.graph_id("https://github.com/MITODL/Mixed_Case.Repo")
|
|
33
|
+
assert "_" not in gid
|
|
34
|
+
assert gid == gid.lower()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_graph_id_strips_scheme():
|
|
38
|
+
with_scheme = config.graph_id("https://github.com/mitodl/x")
|
|
39
|
+
without = config.graph_id("github.com/mitodl/x")
|
|
40
|
+
assert with_scheme == without == "code-github-com-mitodl-x"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_graph_id_is_prefixed():
|
|
44
|
+
assert config.graph_id("github.com/a/b").startswith(config.CODE_GRAPH_PREFIX)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_graph_id_long_repo_truncates_with_hash():
|
|
48
|
+
long_repo = "https://github.com/mitodl/" + "a" * 200
|
|
49
|
+
gid = config.graph_id(long_repo)
|
|
50
|
+
assert len(gid) <= 64
|
|
51
|
+
assert config.GRAPH_ID_RE.match(gid)
|
|
52
|
+
# Distinct long repos disambiguate via the URI hash, never colliding.
|
|
53
|
+
other = config.graph_id("https://github.com/mitodl/" + "a" * 199 + "b")
|
|
54
|
+
assert gid != other
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_graph_id_is_deterministic():
|
|
58
|
+
repo = "https://github.com/mitodl/" + "z" * 100
|
|
59
|
+
assert config.graph_id(repo) == config.graph_id(repo)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_bridge_graph_id_constant():
|
|
63
|
+
assert config.BRIDGE_GRAPH_ID == "code-bridge"
|
|
64
|
+
assert config.GRAPH_ID_RE.match(config.BRIDGE_GRAPH_ID)
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""Tests for witan_code.config's [targets.<name>] routing (load(), _parse_targets).
|
|
2
|
+
|
|
3
|
+
The match-precedence logic itself (match_paths/match_repos/match_hosts/
|
|
4
|
+
match_orgs) lives in witan_core.target_config, with its own test suite at
|
|
5
|
+
packages/witan-core/tests/test_target_config.py; this file covers only
|
|
6
|
+
witan-code's own typed target model and load() integration.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import textwrap
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from witan_code.config import _parse_targets, load
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# ── _parse_targets ────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_parse_targets_empty():
|
|
20
|
+
assert _parse_targets({}) == []
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_parse_targets_basic():
|
|
24
|
+
raw = {
|
|
25
|
+
"targets": {"work": {"code_dir": "/mnt/work-code", "match_orgs": ["mitodl"]}}
|
|
26
|
+
}
|
|
27
|
+
result = _parse_targets(raw)
|
|
28
|
+
assert len(result) == 1
|
|
29
|
+
assert result[0].name == "work"
|
|
30
|
+
assert result[0].code_dir == "/mnt/work-code"
|
|
31
|
+
assert result[0].match_orgs == ["mitodl"]
|
|
32
|
+
assert result[0].match_paths == []
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_parse_targets_targets_not_a_table():
|
|
36
|
+
with pytest.raises(ValueError, match="must be a table"):
|
|
37
|
+
_parse_targets({"targets": ["not", "a", "table"]})
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_parse_targets_target_entry_not_a_table():
|
|
41
|
+
with pytest.raises(ValueError, match="must be a table"):
|
|
42
|
+
_parse_targets({"targets": {"work": "not-a-table"}})
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_parse_targets_invalid_match_orgs_type():
|
|
46
|
+
raw = {"targets": {"work": {"match_orgs": 42}}}
|
|
47
|
+
with pytest.raises(ValueError, match="Expected a list or string"):
|
|
48
|
+
_parse_targets(raw)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ── load() ────────────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@pytest.fixture
|
|
55
|
+
def toml_file(tmp_path):
|
|
56
|
+
"""Write a config.toml and return its path."""
|
|
57
|
+
|
|
58
|
+
def _write(content: str) -> str:
|
|
59
|
+
p = tmp_path / "config.toml"
|
|
60
|
+
p.write_text(textwrap.dedent(content))
|
|
61
|
+
return str(p)
|
|
62
|
+
|
|
63
|
+
return _write
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_load_defaults(monkeypatch, toml_file):
|
|
67
|
+
monkeypatch.setenv("WITAN_CONFIG", toml_file(""))
|
|
68
|
+
monkeypatch.delenv("WITAN_CODE_DIR", raising=False)
|
|
69
|
+
monkeypatch.delenv("WITAN_TARGET", raising=False)
|
|
70
|
+
monkeypatch.setenv("WITAN_REPO", "https://github.com/nobody/nothing")
|
|
71
|
+
|
|
72
|
+
cfg = load()
|
|
73
|
+
assert cfg.target_name is None
|
|
74
|
+
assert str(cfg.code_dir).endswith("/.local/share/witan/code")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_load_env_overrides_file(monkeypatch, toml_file):
|
|
78
|
+
monkeypatch.setenv("WITAN_CONFIG", toml_file('code_dir = "/from-file"'))
|
|
79
|
+
monkeypatch.setenv("WITAN_CODE_DIR", "/from-env")
|
|
80
|
+
monkeypatch.setenv("WITAN_REPO", "https://github.com/nobody/nothing")
|
|
81
|
+
|
|
82
|
+
cfg = load()
|
|
83
|
+
assert str(cfg.code_dir) == "/from-env"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_load_global_file_code_dir(monkeypatch, toml_file):
|
|
87
|
+
monkeypatch.setenv("WITAN_CONFIG", toml_file('code_dir = "/from-file"'))
|
|
88
|
+
monkeypatch.delenv("WITAN_CODE_DIR", raising=False)
|
|
89
|
+
monkeypatch.setenv("WITAN_REPO", "https://github.com/nobody/nothing")
|
|
90
|
+
|
|
91
|
+
cfg = load()
|
|
92
|
+
assert str(cfg.code_dir) == "/from-file"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_load_auto_detects_target_by_org(monkeypatch, toml_file):
|
|
96
|
+
monkeypatch.setenv(
|
|
97
|
+
"WITAN_CONFIG",
|
|
98
|
+
toml_file(
|
|
99
|
+
"""
|
|
100
|
+
[targets.work]
|
|
101
|
+
code_dir = "/work-code"
|
|
102
|
+
match_orgs = ["mitodl"]
|
|
103
|
+
"""
|
|
104
|
+
),
|
|
105
|
+
)
|
|
106
|
+
monkeypatch.setenv("WITAN_REPO", "https://github.com/mitodl/agent-kit")
|
|
107
|
+
monkeypatch.delenv("WITAN_TARGET", raising=False)
|
|
108
|
+
monkeypatch.delenv("WITAN_CODE_DIR", raising=False)
|
|
109
|
+
|
|
110
|
+
cfg = load()
|
|
111
|
+
assert cfg.target_name == "work"
|
|
112
|
+
assert str(cfg.code_dir) == "/work-code"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_load_auto_detects_target_by_path(monkeypatch, toml_file, tmp_path):
|
|
116
|
+
checkout = tmp_path / "code" / "personal" / "dotfiles"
|
|
117
|
+
checkout.mkdir(parents=True)
|
|
118
|
+
monkeypatch.setenv(
|
|
119
|
+
"WITAN_CONFIG",
|
|
120
|
+
toml_file(
|
|
121
|
+
f"""
|
|
122
|
+
[targets.personal]
|
|
123
|
+
code_dir = "/personal-code"
|
|
124
|
+
match_paths = [{str(tmp_path / "code" / "personal")!r}]
|
|
125
|
+
"""
|
|
126
|
+
),
|
|
127
|
+
)
|
|
128
|
+
monkeypatch.setenv("CLAUDE_PROJECT_DIR", str(checkout))
|
|
129
|
+
monkeypatch.setenv("WITAN_REPO", "")
|
|
130
|
+
monkeypatch.delenv("WITAN_TARGET", raising=False)
|
|
131
|
+
monkeypatch.delenv("WITAN_CODE_DIR", raising=False)
|
|
132
|
+
|
|
133
|
+
cfg = load()
|
|
134
|
+
assert cfg.target_name == "personal"
|
|
135
|
+
assert str(cfg.code_dir) == "/personal-code"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_load_explicit_target_arg(monkeypatch, toml_file):
|
|
139
|
+
monkeypatch.setenv(
|
|
140
|
+
"WITAN_CONFIG",
|
|
141
|
+
toml_file(
|
|
142
|
+
"""
|
|
143
|
+
[targets.personal]
|
|
144
|
+
code_dir = "/personal-code"
|
|
145
|
+
match_orgs = ["alice"]
|
|
146
|
+
"""
|
|
147
|
+
),
|
|
148
|
+
)
|
|
149
|
+
monkeypatch.setenv("WITAN_REPO", "https://github.com/mitodl/agent-kit")
|
|
150
|
+
monkeypatch.delenv("WITAN_TARGET", raising=False)
|
|
151
|
+
monkeypatch.delenv("WITAN_CODE_DIR", raising=False)
|
|
152
|
+
|
|
153
|
+
cfg = load(target="personal")
|
|
154
|
+
assert cfg.target_name == "personal"
|
|
155
|
+
assert str(cfg.code_dir) == "/personal-code"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_load_witan_target_env(monkeypatch, toml_file):
|
|
159
|
+
monkeypatch.setenv(
|
|
160
|
+
"WITAN_CONFIG",
|
|
161
|
+
toml_file(
|
|
162
|
+
"""
|
|
163
|
+
[targets.work]
|
|
164
|
+
code_dir = "/work-code"
|
|
165
|
+
match_orgs = ["mitodl"]
|
|
166
|
+
"""
|
|
167
|
+
),
|
|
168
|
+
)
|
|
169
|
+
monkeypatch.setenv("WITAN_TARGET", "work")
|
|
170
|
+
monkeypatch.delenv("WITAN_CODE_DIR", raising=False)
|
|
171
|
+
|
|
172
|
+
cfg = load()
|
|
173
|
+
assert cfg.target_name == "work"
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def test_load_target_inherits_global_defaults(monkeypatch, toml_file):
|
|
177
|
+
"""A target that omits author falls through to the global config value."""
|
|
178
|
+
monkeypatch.setenv(
|
|
179
|
+
"WITAN_CONFIG",
|
|
180
|
+
toml_file(
|
|
181
|
+
"""
|
|
182
|
+
author = "Alice"
|
|
183
|
+
|
|
184
|
+
[targets.work]
|
|
185
|
+
code_dir = "/work-code"
|
|
186
|
+
match_orgs = ["mitodl"]
|
|
187
|
+
"""
|
|
188
|
+
),
|
|
189
|
+
)
|
|
190
|
+
monkeypatch.setenv("WITAN_REPO", "https://github.com/mitodl/agent-kit")
|
|
191
|
+
monkeypatch.delenv("WITAN_TARGET", raising=False)
|
|
192
|
+
monkeypatch.delenv("WITAN_AUTHOR", raising=False)
|
|
193
|
+
monkeypatch.delenv("WITAN_CODE_DIR", raising=False)
|
|
194
|
+
|
|
195
|
+
cfg = load()
|
|
196
|
+
assert cfg.target_name == "work"
|
|
197
|
+
assert cfg.author == "Alice"
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def test_load_unknown_explicit_target_raises(monkeypatch, toml_file):
|
|
201
|
+
monkeypatch.setenv("WITAN_CONFIG", toml_file('[targets.work]\nmatch_orgs = ["x"]'))
|
|
202
|
+
monkeypatch.delenv("WITAN_TARGET", raising=False)
|
|
203
|
+
|
|
204
|
+
with pytest.raises(ValueError, match="Unknown target 'nope'"):
|
|
205
|
+
load(target="nope")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def test_load_missing_config_file_uses_defaults(monkeypatch, tmp_path):
|
|
209
|
+
monkeypatch.setenv("WITAN_CONFIG", str(tmp_path / "nonexistent.toml"))
|
|
210
|
+
monkeypatch.delenv("WITAN_TARGET", raising=False)
|
|
211
|
+
monkeypatch.setenv("WITAN_REPO", "https://github.com/nobody/nothing")
|
|
212
|
+
|
|
213
|
+
cfg = load()
|
|
214
|
+
assert cfg.target_name is None
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def test_load_malformed_toml_raises(monkeypatch, tmp_path):
|
|
218
|
+
bad = tmp_path / "bad.toml"
|
|
219
|
+
bad.write_text("this is not [ valid toml !!!")
|
|
220
|
+
monkeypatch.setenv("WITAN_CONFIG", str(bad))
|
|
221
|
+
|
|
222
|
+
with pytest.raises(ValueError, match="Failed to parse config file"):
|
|
223
|
+
load()
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def test_load_shares_config_file_with_witan(monkeypatch, toml_file):
|
|
227
|
+
"""One [targets.<name>] block can carry both witan's server/graph and
|
|
228
|
+
witan-code's code_dir — this server only reads the fields it knows."""
|
|
229
|
+
monkeypatch.setenv(
|
|
230
|
+
"WITAN_CONFIG",
|
|
231
|
+
toml_file(
|
|
232
|
+
"""
|
|
233
|
+
[targets.work]
|
|
234
|
+
server = "http://work:8080"
|
|
235
|
+
graph = "council-work"
|
|
236
|
+
code_dir = "/work-code"
|
|
237
|
+
match_orgs = ["mitodl"]
|
|
238
|
+
"""
|
|
239
|
+
),
|
|
240
|
+
)
|
|
241
|
+
monkeypatch.setenv("WITAN_REPO", "https://github.com/mitodl/agent-kit")
|
|
242
|
+
monkeypatch.delenv("WITAN_TARGET", raising=False)
|
|
243
|
+
monkeypatch.delenv("WITAN_CODE_DIR", raising=False)
|
|
244
|
+
|
|
245
|
+
cfg = load()
|
|
246
|
+
assert cfg.target_name == "work"
|
|
247
|
+
assert str(cfg.code_dir) == "/work-code"
|
|
@@ -18,6 +18,42 @@ def test_detect_env_override(monkeypatch):
|
|
|
18
18
|
assert repo.detect() == "https://github.com/test/cg"
|
|
19
19
|
|
|
20
20
|
|
|
21
|
+
def test_detect_env_override_is_normalised(monkeypatch):
|
|
22
|
+
monkeypatch.setenv("WITAN_REPO", "https://GitHub.com/MITODL/OL-Django")
|
|
23
|
+
assert repo.detect() == "https://github.com/mitodl/ol-django"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@pytest.mark.skipif(shutil.which("git") is None, reason="git not on PATH")
|
|
27
|
+
def test_detect_empty_env_disables_autodetection(monkeypatch, tmp_path):
|
|
28
|
+
"""An explicit empty WITAN_REPO must disable auto-detection outright,
|
|
29
|
+
matching witan-council's repo.detect — not fall through to git remote
|
|
30
|
+
lookup (the walrus-operator `if env_repo := ...` form treats "" as
|
|
31
|
+
falsy and used to do exactly that)."""
|
|
32
|
+
monkeypatch.setenv("WITAN_REPO", "")
|
|
33
|
+
subprocess.run(["git", "init", "-q", str(tmp_path)], check=True)
|
|
34
|
+
subprocess.run(
|
|
35
|
+
[
|
|
36
|
+
"git",
|
|
37
|
+
"-C",
|
|
38
|
+
str(tmp_path),
|
|
39
|
+
"remote",
|
|
40
|
+
"add",
|
|
41
|
+
"origin",
|
|
42
|
+
"git@github.com:mitodl/should-not-be-detected.git",
|
|
43
|
+
],
|
|
44
|
+
check=True,
|
|
45
|
+
)
|
|
46
|
+
assert repo.detect(start=tmp_path) is None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_detect_override_arg_is_normalised(monkeypatch):
|
|
50
|
+
monkeypatch.delenv("WITAN_REPO", raising=False)
|
|
51
|
+
assert (
|
|
52
|
+
repo.detect(override="git@github.com:MITODL/OL-Django.git")
|
|
53
|
+
== "https://github.com/mitodl/ol-django"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
21
57
|
@pytest.mark.skipif(shutil.which("git") is None, reason="git not on PATH")
|
|
22
58
|
def test_detect_tolerates_multivalued_fetch(tmp_path, monkeypatch):
|
|
23
59
|
# git allows several `fetch =` lines under a remote; configparser rejects
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
8
|
+
from witan_core.config_file import load_toml as _load_toml_shared
|
|
9
|
+
from witan_core.target_config import (
|
|
10
|
+
local_project_path,
|
|
11
|
+
match_target,
|
|
12
|
+
parse_target_tables,
|
|
13
|
+
to_list,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
from . import repo as repo_module
|
|
17
|
+
|
|
18
|
+
# Bundled query files, resolved relative to this file.
|
|
19
|
+
_QUERIES_DIR = Path(__file__).parent / "queries"
|
|
20
|
+
_SCHEMA_FILE = Path(__file__).parent / "schema" / "code-schema.pg"
|
|
21
|
+
_BRIDGE_SCHEMA_FILE = Path(__file__).parent / "schema" / "bridge-schema.pg"
|
|
22
|
+
_DEFAULT_CODE_DIR = Path.home() / ".local" / "share" / "witan" / "code"
|
|
23
|
+
|
|
24
|
+
DEFAULT_CONFIG_PATH = Path.home() / ".config" / "witan" / "config.toml"
|
|
25
|
+
"""Shared with witan (witan-council) — see witan_core.config_file. A
|
|
26
|
+
[targets.<name>] block can carry ``code_dir`` here alongside witan's
|
|
27
|
+
``server``/``graph``/``token`` under the same name, routed together."""
|
|
28
|
+
|
|
29
|
+
# Filename of the single shared cross-repo bridge store, a sibling of the
|
|
30
|
+
# per-repo `<slug>.omni` stores in code_dir. Not routed through sanitize_slug
|
|
31
|
+
# (whose .strip("_") would eat the leading underscore); no real repo slug
|
|
32
|
+
# resolves to this name.
|
|
33
|
+
BRIDGE_STORE_NAME = "_bridge.omni"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class Config:
|
|
38
|
+
code_dir: Path
|
|
39
|
+
"""Directory holding per-repo code stores (one ``<slug>.omni`` each)."""
|
|
40
|
+
|
|
41
|
+
author: str
|
|
42
|
+
"""Attribution string (carried for parity with Layer 1; unused on inserts)."""
|
|
43
|
+
|
|
44
|
+
queries_dir: Path
|
|
45
|
+
"""Directory containing code_read.gq, code_mutations.gq, delete.gq."""
|
|
46
|
+
|
|
47
|
+
schema_file: Path
|
|
48
|
+
"""Path to code-schema.pg, used to lazily init a per-repo store."""
|
|
49
|
+
|
|
50
|
+
bridge_schema_file: Path
|
|
51
|
+
"""Path to bridge-schema.pg, used to lazily init the shared bridge store."""
|
|
52
|
+
|
|
53
|
+
target_name: str | None = None
|
|
54
|
+
"""Name of the matched [targets.<name>] section, or None for global defaults."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class _Target(BaseModel):
|
|
58
|
+
model_config = ConfigDict(frozen=True)
|
|
59
|
+
|
|
60
|
+
name: str
|
|
61
|
+
code_dir: str | None = None
|
|
62
|
+
author: str | None = None
|
|
63
|
+
match_orgs: list[str] = Field(default_factory=list)
|
|
64
|
+
match_repos: list[str] = Field(default_factory=list)
|
|
65
|
+
match_hosts: list[str] = Field(default_factory=list)
|
|
66
|
+
match_paths: list[str] = Field(default_factory=list)
|
|
67
|
+
|
|
68
|
+
@field_validator(
|
|
69
|
+
"match_orgs", "match_repos", "match_hosts", "match_paths", mode="before"
|
|
70
|
+
)
|
|
71
|
+
@classmethod
|
|
72
|
+
def _normalize_match_list(cls, v: object) -> list[str]:
|
|
73
|
+
return to_list(v)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _load_toml() -> dict:
|
|
77
|
+
"""Load WITAN_CONFIG path or ~/.config/witan/config.toml. Returns {} on missing file.
|
|
78
|
+
|
|
79
|
+
Reads the same file as witan (witan-council) — see DEFAULT_CONFIG_PATH.
|
|
80
|
+
"""
|
|
81
|
+
return _load_toml_shared(DEFAULT_CONFIG_PATH)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _parse_targets(raw: dict) -> list[_Target]:
|
|
85
|
+
return [_Target(name=name, **cfg) for name, cfg in parse_target_tables(raw).items()]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _first(*values: str | None, default: str | None = None) -> str | None:
|
|
89
|
+
for v in values:
|
|
90
|
+
if v:
|
|
91
|
+
return v
|
|
92
|
+
return default
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def load(target: str | None = None) -> Config:
|
|
96
|
+
"""Load config from config.toml + environment, selecting a named target if applicable.
|
|
97
|
+
|
|
98
|
+
Resolution order (highest -> lowest precedence):
|
|
99
|
+
1. Environment variables (WITAN_CODE_DIR, WITAN_AUTHOR)
|
|
100
|
+
2. Named target: ``target`` arg > WITAN_TARGET env var > auto-detect by
|
|
101
|
+
repo or local checkout path
|
|
102
|
+
3. Global values in config.toml
|
|
103
|
+
4. Hardcoded defaults
|
|
104
|
+
|
|
105
|
+
Shares config.toml (``WITAN_CONFIG`` / ``~/.config/witan/config.toml``)
|
|
106
|
+
and the ``[targets.<name>]`` tables with witan (witan-council) — the same
|
|
107
|
+
target block can carry witan's ``server``/``graph``/``token`` alongside
|
|
108
|
+
this server's ``code_dir``, routed together. See ``witan.config.load()``'s
|
|
109
|
+
docstring for the full ``match_paths``/``match_repos``/``match_hosts``/
|
|
110
|
+
``match_orgs`` precedence (shared logic: ``witan_core.target_config``).
|
|
111
|
+
|
|
112
|
+
Raises ValueError for an explicitly-requested target that is not defined.
|
|
113
|
+
"""
|
|
114
|
+
file_cfg = _load_toml()
|
|
115
|
+
targets = _parse_targets(file_cfg)
|
|
116
|
+
|
|
117
|
+
explicit = target or os.environ.get("WITAN_TARGET")
|
|
118
|
+
if explicit:
|
|
119
|
+
selected = next((t for t in targets if t.name == explicit), None)
|
|
120
|
+
if selected is None:
|
|
121
|
+
available = ", ".join(t.name for t in targets) or "(none defined)"
|
|
122
|
+
raise ValueError(f"Unknown target {explicit!r}. Available: {available}")
|
|
123
|
+
else:
|
|
124
|
+
selected = match_target(
|
|
125
|
+
targets, repo_uri=repo_module.detect(), local_path=local_project_path()
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
raw_code_dir = _first(
|
|
129
|
+
os.environ.get("WITAN_CODE_DIR"),
|
|
130
|
+
selected.code_dir if selected else None,
|
|
131
|
+
file_cfg.get("code_dir"),
|
|
132
|
+
default=str(_DEFAULT_CODE_DIR),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
return Config(
|
|
136
|
+
code_dir=Path(raw_code_dir).expanduser(),
|
|
137
|
+
author=_first(
|
|
138
|
+
os.environ.get("WITAN_AUTHOR"),
|
|
139
|
+
selected.author if selected else None,
|
|
140
|
+
file_cfg.get("author"),
|
|
141
|
+
os.environ.get("USER"),
|
|
142
|
+
default="unknown",
|
|
143
|
+
),
|
|
144
|
+
queries_dir=_QUERIES_DIR,
|
|
145
|
+
schema_file=_SCHEMA_FILE,
|
|
146
|
+
bridge_schema_file=_BRIDGE_SCHEMA_FILE,
|
|
147
|
+
target_name=selected.name if selected else None,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def sanitize_slug(slug: str) -> str:
|
|
152
|
+
"""Make a repo slug safe for use as a LOCAL filename / branch-name component.
|
|
153
|
+
|
|
154
|
+
Emits underscores (``[/:]+`` → ``_``). Fine for local ``<slug>.omni`` store
|
|
155
|
+
dirs and per-repo branch prefixes, but NOT valid as a shared-cluster graph
|
|
156
|
+
id — omnigraph graph ids must match ``^[a-zA-Z0-9-]{1,64}$`` (no
|
|
157
|
+
underscores). Use :func:`graph_id` to derive the cluster ``--graph`` id.
|
|
158
|
+
"""
|
|
159
|
+
return re.sub(r"[/:]+", "_", slug).strip("_")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# ── Shared-cluster graph id ──────────────────────────────────────────────────
|
|
163
|
+
#
|
|
164
|
+
# On the deployed omnigraph-server, each repo's code graph is a distinct cluster
|
|
165
|
+
# graph addressed as `--server <url> --graph <id>`. `graph_id()` is the CANONICAL
|
|
166
|
+
# repo-URI → graph-id function.
|
|
167
|
+
#
|
|
168
|
+
# SHARED CONTRACT — this exact algorithm is mirrored by ol-infrastructure's
|
|
169
|
+
# Pulumi provisioning (toolhive_witan/data_tier.py, which declares each
|
|
170
|
+
# `code-<repo>` graph in the cluster.yaml ConfigMap). witan-code selects the
|
|
171
|
+
# `--graph` id and provisioning declares the same id; they MUST agree
|
|
172
|
+
# byte-for-byte or a client will address a graph the cluster never created. Any
|
|
173
|
+
# change here has to land in lockstep on both sides — see task
|
|
174
|
+
# tk-code-graph-deployment-topology-shared-per-repo-c-cac400.
|
|
175
|
+
CODE_GRAPH_PREFIX = "code-"
|
|
176
|
+
# The shared cross-repo bridge graph (Layer 2.5), analogous to the local
|
|
177
|
+
# `_bridge.omni` store. Fixed id, not derived from any repo.
|
|
178
|
+
BRIDGE_GRAPH_ID = "code-bridge"
|
|
179
|
+
# omnigraph's graph-id constraint. Enforced by construction in `graph_id`.
|
|
180
|
+
_GRAPH_ID_MAX_LEN = 64
|
|
181
|
+
GRAPH_ID_RE = re.compile(r"^[a-zA-Z0-9-]{1,64}$")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def graph_id(repo: str) -> str:
|
|
185
|
+
"""Canonical cluster graph-id for ``repo``'s code graph.
|
|
186
|
+
|
|
187
|
+
e.g. ``https://github.com/mitodl/ol-django`` → ``code-github-com-mitodl-ol-django``.
|
|
188
|
+
|
|
189
|
+
Strip the URI scheme, collapse every run of non-alphanumerics to ``-``,
|
|
190
|
+
lowercase, and prefix ``code-``. The result always satisfies
|
|
191
|
+
:data:`GRAPH_ID_RE`. Slugs that would exceed :data:`_GRAPH_ID_MAX_LEN` are
|
|
192
|
+
truncated and disambiguated with a hash of the full repo URI (so distinct
|
|
193
|
+
long repos never collide on the same id).
|
|
194
|
+
"""
|
|
195
|
+
body = re.sub(r"(?i)^[a-z][a-z0-9+.-]*://", "", repo) # strip scheme
|
|
196
|
+
body = re.sub(r"[^a-zA-Z0-9]+", "-", body).strip("-").lower()
|
|
197
|
+
candidate = f"{CODE_GRAPH_PREFIX}{body}"
|
|
198
|
+
if len(candidate) <= _GRAPH_ID_MAX_LEN:
|
|
199
|
+
return candidate
|
|
200
|
+
digest = hashlib.sha256(repo.encode()).hexdigest()[:8]
|
|
201
|
+
keep = _GRAPH_ID_MAX_LEN - len(CODE_GRAPH_PREFIX) - len(digest) - 1
|
|
202
|
+
return f"{CODE_GRAPH_PREFIX}{body[:keep].strip('-')}-{digest}"
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def store_path(slug: str, code_dir: Path | None = None) -> Path:
|
|
206
|
+
"""Resolve the per-repo store path for ``slug``."""
|
|
207
|
+
base = code_dir or load().code_dir
|
|
208
|
+
return base / f"{sanitize_slug(slug)}.omni"
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def bridge_store_path(code_dir: Path | None = None) -> Path:
|
|
212
|
+
"""Resolve the shared cross-repo bridge store path."""
|
|
213
|
+
base = code_dir or load().code_dir
|
|
214
|
+
return base / BRIDGE_STORE_NAME
|
|
@@ -44,7 +44,7 @@ async def choose_repo(
|
|
|
44
44
|
doesn't match any candidate — returns ``None``, meaning "keep every
|
|
45
45
|
match" (today's behavior, unchanged).
|
|
46
46
|
"""
|
|
47
|
-
answer = await text(ctx, message, default="")
|
|
47
|
+
answer = await text(ctx, message, default="", title="Repo")
|
|
48
48
|
if not answer:
|
|
49
49
|
return None
|
|
50
50
|
normalized = answer.strip().casefold()
|
|
@@ -29,12 +29,13 @@ class OmnigraphClient(_BaseOmnigraphClient):
|
|
|
29
29
|
queries_dir: Path,
|
|
30
30
|
token: str | None = None,
|
|
31
31
|
branch: str | None = None,
|
|
32
|
+
graph_id: str | None = None,
|
|
32
33
|
) -> None:
|
|
33
34
|
# Target omnigraph branch for query/mutate/load; None = the store's main
|
|
34
35
|
# branch. Loads pass `--from main` so the branch forks lazily on first
|
|
35
36
|
# write (docs/BRANCH_INDEXING.md).
|
|
36
37
|
self.branch = branch
|
|
37
|
-
super().__init__(graph_uri, queries_dir, token)
|
|
38
|
+
super().__init__(graph_uri, queries_dir, token, graph_id=graph_id)
|
|
38
39
|
|
|
39
40
|
def load(self, records: list[dict], mode: str = "merge") -> None:
|
|
40
41
|
"""Bulk-load node/edge records via one ``omnigraph load`` call.
|
|
@@ -12,8 +12,13 @@ def detect(override: str | None = None, start: Path | None = None) -> str | None
|
|
|
12
12
|
Return a canonical repo key (HTTPS URI) for ``start`` (or the cwd).
|
|
13
13
|
|
|
14
14
|
Resolution order:
|
|
15
|
-
1. ``override`` parameter (explicit caller value)
|
|
16
|
-
|
|
15
|
+
1. ``override`` parameter (explicit caller value) — canonicalized the
|
|
16
|
+
same way an auto-detected remote is (see ``normalise``); an explicit
|
|
17
|
+
empty string means "no repo scope"
|
|
18
|
+
2. ``WITAN_REPO`` environment variable — canonicalized the same way; an
|
|
19
|
+
explicit empty string also disables auto-detection here, matching
|
|
20
|
+
witan-council's ``repo.detect`` (e.g. set in a global MCP server
|
|
21
|
+
config where the server CWD is not the session's repo)
|
|
17
22
|
3. ``git remote get-url origin`` (handles worktrees and multi-valued
|
|
18
23
|
config keys that ``configparser`` rejects)
|
|
19
24
|
4. ``origin`` remote URL parsed from the nearest ``.git/config``
|
|
@@ -21,10 +26,11 @@ def detect(override: str | None = None, start: Path | None = None) -> str | None
|
|
|
21
26
|
6. ``None`` — no repo context available
|
|
22
27
|
"""
|
|
23
28
|
if override is not None:
|
|
24
|
-
return override
|
|
29
|
+
return normalise(override) if override else None
|
|
25
30
|
|
|
26
|
-
|
|
27
|
-
|
|
31
|
+
env_repo = os.environ.get("WITAN_REPO")
|
|
32
|
+
if env_repo is not None:
|
|
33
|
+
return normalise(env_repo) if env_repo else None
|
|
28
34
|
|
|
29
35
|
base = start or Path.cwd()
|
|
30
36
|
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
// Cross-Repo Context Bridge (Layer 2.5) — shared
|
|
1
|
+
// Cross-Repo Context Bridge (Layer 2.5) — one shared store across all repos.
|
|
2
|
+
//
|
|
3
|
+
// Deployed as the `code-bridge` graph on the shared, S3-backed omnigraph-server
|
|
4
|
+
// cluster (config.BRIDGE_GRAPH_ID); locally it is the `_bridge.omni` store.
|
|
2
5
|
//
|
|
3
6
|
// One flat node per interface binding. Cross-repo linkages are computed by
|
|
4
7
|
// GROUPING bindings on (kind, key_norm) where roles/repos differ — there are
|
|
@@ -8,7 +11,8 @@
|
|
|
8
11
|
// run + every PostToolUse reindex hook). Flat bindings are each scoped to their
|
|
9
12
|
// own repo+file, so concurrent writers never touch the same row.
|
|
10
13
|
//
|
|
11
|
-
//
|
|
14
|
+
// Re-derivable, like the per-repo Layer-2 graphs; seed the shared copy by
|
|
15
|
+
// re-indexing on the server or export→load, never by copying a local store to S3.
|
|
12
16
|
//
|
|
13
17
|
// Id convention:
|
|
14
18
|
// InterfaceBinding.slug = repo|file|kind|key_norm|role|symbol_id
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
// Code Graph (Layer 2) — per-repo tree-sitter-derived symbol graph.
|
|
2
2
|
//
|
|
3
|
-
// One
|
|
3
|
+
// One graph per repository. Deployed as a distinct graph on the shared,
|
|
4
|
+
// S3-backed omnigraph-server cluster, addressed as `--graph code-<repo>` (the
|
|
5
|
+
// canonical id from witan_code.config.graph_id); a local `<slug>.omni` store is
|
|
6
|
+
// the standalone/dev fallback. The graph is re-derivable from source either way,
|
|
7
|
+
// so seed the shared copy by re-indexing on the server or export→load — never
|
|
8
|
+
// copy a local store up to S3 (Lance embeds root-relative paths).
|
|
4
9
|
// Composes with witan (Layer 1) via soft symbol-ID references:
|
|
5
10
|
// Layer-1 nodes store symbol ids of the form `repo#path::Qualified.Name`,
|
|
6
11
|
// which resolve here without a hard cross-store edge.
|
|
@@ -236,6 +236,7 @@ async def _confirm_and_reindex(
|
|
|
236
236
|
f"No code graph indexed yet for {repo}. Index it now? "
|
|
237
237
|
"(may take a while on a large repo)",
|
|
238
238
|
default_when_unsupported=False,
|
|
239
|
+
title="Index now?",
|
|
239
240
|
)
|
|
240
241
|
if not ok:
|
|
241
242
|
return None
|
|
@@ -260,6 +261,7 @@ async def _confirm_and_reindex_bridge(ctx: Context | None) -> OmnigraphClient |
|
|
|
260
261
|
f"No cross-repo graph indexed yet. Index the current repo ({repo}) "
|
|
261
262
|
"now to start building it?",
|
|
262
263
|
default_when_unsupported=False,
|
|
264
|
+
title="Index now?",
|
|
263
265
|
)
|
|
264
266
|
if not ok:
|
|
265
267
|
return None
|
|
@@ -497,6 +499,7 @@ async def code_symbols_in_file(
|
|
|
497
499
|
"No repo detected for this file (not in a git repo, or no remote). "
|
|
498
500
|
"Enter its canonical repo URI (e.g. https://github.com/org/name):",
|
|
499
501
|
default="",
|
|
502
|
+
title="Repo URI",
|
|
500
503
|
)
|
|
501
504
|
if not slug:
|
|
502
505
|
return []
|
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
import os
|
|
2
|
-
import re
|
|
3
|
-
from dataclasses import dataclass
|
|
4
|
-
from pathlib import Path
|
|
5
|
-
|
|
6
|
-
# Bundled query files, resolved relative to this file.
|
|
7
|
-
_QUERIES_DIR = Path(__file__).parent / "queries"
|
|
8
|
-
_SCHEMA_FILE = Path(__file__).parent / "schema" / "code-schema.pg"
|
|
9
|
-
_BRIDGE_SCHEMA_FILE = Path(__file__).parent / "schema" / "bridge-schema.pg"
|
|
10
|
-
_DEFAULT_CODE_DIR = Path.home() / ".local" / "share" / "witan" / "code"
|
|
11
|
-
|
|
12
|
-
# Filename of the single shared cross-repo bridge store, a sibling of the
|
|
13
|
-
# per-repo `<slug>.omni` stores in code_dir. Not routed through sanitize_slug
|
|
14
|
-
# (whose .strip("_") would eat the leading underscore); no real repo slug
|
|
15
|
-
# resolves to this name.
|
|
16
|
-
BRIDGE_STORE_NAME = "_bridge.omni"
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
@dataclass(frozen=True)
|
|
20
|
-
class Config:
|
|
21
|
-
code_dir: Path
|
|
22
|
-
"""Directory holding per-repo code stores (one ``<slug>.omni`` each)."""
|
|
23
|
-
|
|
24
|
-
author: str
|
|
25
|
-
"""Attribution string (carried for parity with Layer 1; unused on inserts)."""
|
|
26
|
-
|
|
27
|
-
queries_dir: Path
|
|
28
|
-
"""Directory containing code_read.gq, code_mutations.gq, delete.gq."""
|
|
29
|
-
|
|
30
|
-
schema_file: Path
|
|
31
|
-
"""Path to code-schema.pg, used to lazily init a per-repo store."""
|
|
32
|
-
|
|
33
|
-
bridge_schema_file: Path
|
|
34
|
-
"""Path to bridge-schema.pg, used to lazily init the shared bridge store."""
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
def load() -> Config:
|
|
38
|
-
"""Load config from environment. All variables are optional with defaults."""
|
|
39
|
-
return Config(
|
|
40
|
-
code_dir=Path(os.environ.get("WITAN_CODE_DIR", str(_DEFAULT_CODE_DIR))),
|
|
41
|
-
author=os.environ.get(
|
|
42
|
-
"WITAN_AUTHOR",
|
|
43
|
-
os.environ.get("USER", "unknown"),
|
|
44
|
-
),
|
|
45
|
-
queries_dir=_QUERIES_DIR,
|
|
46
|
-
schema_file=_SCHEMA_FILE,
|
|
47
|
-
bridge_schema_file=_BRIDGE_SCHEMA_FILE,
|
|
48
|
-
)
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def sanitize_slug(slug: str) -> str:
|
|
52
|
-
"""Make a repo slug safe for use as a filename component."""
|
|
53
|
-
return re.sub(r"[/:]+", "_", slug).strip("_")
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
def store_path(slug: str, code_dir: Path | None = None) -> Path:
|
|
57
|
-
"""Resolve the per-repo store path for ``slug``."""
|
|
58
|
-
base = code_dir or load().code_dir
|
|
59
|
-
return base / f"{sanitize_slug(slug)}.omni"
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
def bridge_store_path(code_dir: Path | None = None) -> Path:
|
|
63
|
-
"""Resolve the shared cross-repo bridge store path."""
|
|
64
|
-
base = code_dir or load().code_dir
|
|
65
|
-
return base / BRIDGE_STORE_NAME
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|