ado-search 0.1.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 (32) hide show
  1. ado_search-0.1.0/PKG-INFO +98 -0
  2. ado_search-0.1.0/README.md +71 -0
  3. ado_search-0.1.0/pyproject.toml +49 -0
  4. ado_search-0.1.0/setup.cfg +4 -0
  5. ado_search-0.1.0/src/ado_search/__init__.py +1 -0
  6. ado_search-0.1.0/src/ado_search/auth.py +159 -0
  7. ado_search-0.1.0/src/ado_search/cli.py +178 -0
  8. ado_search-0.1.0/src/ado_search/config.py +71 -0
  9. ado_search-0.1.0/src/ado_search/db.py +225 -0
  10. ado_search-0.1.0/src/ado_search/markdown.py +117 -0
  11. ado_search-0.1.0/src/ado_search/runner.py +77 -0
  12. ado_search-0.1.0/src/ado_search/search.py +90 -0
  13. ado_search-0.1.0/src/ado_search/sync_odata.py +202 -0
  14. ado_search-0.1.0/src/ado_search/sync_wiki.py +188 -0
  15. ado_search-0.1.0/src/ado_search/sync_workitems.py +340 -0
  16. ado_search-0.1.0/src/ado_search.egg-info/PKG-INFO +98 -0
  17. ado_search-0.1.0/src/ado_search.egg-info/SOURCES.txt +30 -0
  18. ado_search-0.1.0/src/ado_search.egg-info/dependency_links.txt +1 -0
  19. ado_search-0.1.0/src/ado_search.egg-info/entry_points.txt +2 -0
  20. ado_search-0.1.0/src/ado_search.egg-info/requires.txt +8 -0
  21. ado_search-0.1.0/src/ado_search.egg-info/top_level.txt +1 -0
  22. ado_search-0.1.0/tests/test_auth.py +102 -0
  23. ado_search-0.1.0/tests/test_cli.py +46 -0
  24. ado_search-0.1.0/tests/test_config.py +38 -0
  25. ado_search-0.1.0/tests/test_db.py +242 -0
  26. ado_search-0.1.0/tests/test_e2e.py +75 -0
  27. ado_search-0.1.0/tests/test_markdown.py +62 -0
  28. ado_search-0.1.0/tests/test_runner.py +34 -0
  29. ado_search-0.1.0/tests/test_search.py +80 -0
  30. ado_search-0.1.0/tests/test_sync_odata.py +284 -0
  31. ado_search-0.1.0/tests/test_sync_wiki.py +99 -0
  32. ado_search-0.1.0/tests/test_sync_workitems.py +144 -0
@@ -0,0 +1,98 @@
1
+ Metadata-Version: 2.4
2
+ Name: ado-search
3
+ Version: 0.1.0
4
+ Summary: Sync and search Azure DevOps work items and wiki pages for AI agents
5
+ Author: Samuel Hurley
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/HurleySk/ado-search
8
+ Project-URL: Repository, https://github.com/HurleySk/ado-search
9
+ Project-URL: Issues, https://github.com/HurleySk/ado-search/issues
10
+ Keywords: azure-devops,search,work-items,wiki,agents
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Software Development :: Libraries
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+ Requires-Dist: click>=8.0
23
+ Requires-Dist: tomli>=2.0; python_version < "3.11"
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest>=7.0; extra == "dev"
26
+ Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
27
+
28
+ # ado-search
29
+
30
+ Sync and search Azure DevOps work items and wiki pages locally for AI agents.
31
+
32
+ ## Install
33
+
34
+ ```bash
35
+ pip install ado-search
36
+ ```
37
+
38
+ Or from source:
39
+
40
+ ```bash
41
+ pip install git+https://github.com/HurleySk/ado-search.git
42
+ ```
43
+
44
+ ## Quick Start
45
+
46
+ ```bash
47
+ # Configure (requires az login first)
48
+ ado-search init --org https://dev.azure.com/yourorg --project YourProject
49
+
50
+ # Pull data
51
+ ado-search sync
52
+
53
+ # Search
54
+ ado-search search "login bug"
55
+ ado-search search "auth" --type Bug --state Active
56
+ ado-search search "setup guide" --format paths
57
+ ```
58
+
59
+ ## How It Works
60
+
61
+ 1. **Sync** pulls work items and wiki pages from Azure DevOps
62
+ - Tries **OData analytics** first (fetches all items in one call — fast)
63
+ - Falls back to **az devops CLI** if analytics isn't available
64
+ 2. Content is stored as compact **markdown files** (one per item)
65
+ 3. Metadata is indexed in **SQLite with FTS5** for fast full-text search
66
+ 4. Agents search the index, then read only the files they need — minimal context
67
+
68
+ ## Commands
69
+
70
+ | Command | Description |
71
+ |---------|-------------|
72
+ | `ado-search init` | Configure organization, project, and auth |
73
+ | `ado-search sync` | Pull latest data from Azure DevOps |
74
+ | `ado-search search "query"` | Full-text search with filters |
75
+ | `ado-search show <id>` | Display full content of an item |
76
+
77
+ ## Auth Methods
78
+
79
+ - **az-cli** (default): Uses `az devops` commands. Requires `az login`.
80
+ - **az-powershell**: Uses Azure PowerShell + REST. Requires `Connect-AzAccount`.
81
+
82
+ Set via `ado-search init --auth-method az-powershell` or in `config.toml`.
83
+
84
+ ## Search Formats
85
+
86
+ ```bash
87
+ ado-search search "query" # compact (default)
88
+ ado-search search "query" --format detail # with description snippets
89
+ ado-search search "query" --format json # machine-readable
90
+ ado-search search "query" --format paths # file paths only (for agent piping)
91
+ ado-search search "query" --type Bug --state Active # filtered
92
+ ```
93
+
94
+ ## Prerequisites
95
+
96
+ - Python 3.10+
97
+ - Azure CLI with `azure-devops` extension (`az extension add --name azure-devops`)
98
+ - `az login` completed
@@ -0,0 +1,71 @@
1
+ # ado-search
2
+
3
+ Sync and search Azure DevOps work items and wiki pages locally for AI agents.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install ado-search
9
+ ```
10
+
11
+ Or from source:
12
+
13
+ ```bash
14
+ pip install git+https://github.com/HurleySk/ado-search.git
15
+ ```
16
+
17
+ ## Quick Start
18
+
19
+ ```bash
20
+ # Configure (requires az login first)
21
+ ado-search init --org https://dev.azure.com/yourorg --project YourProject
22
+
23
+ # Pull data
24
+ ado-search sync
25
+
26
+ # Search
27
+ ado-search search "login bug"
28
+ ado-search search "auth" --type Bug --state Active
29
+ ado-search search "setup guide" --format paths
30
+ ```
31
+
32
+ ## How It Works
33
+
34
+ 1. **Sync** pulls work items and wiki pages from Azure DevOps
35
+ - Tries **OData analytics** first (fetches all items in one call — fast)
36
+ - Falls back to **az devops CLI** if analytics isn't available
37
+ 2. Content is stored as compact **markdown files** (one per item)
38
+ 3. Metadata is indexed in **SQLite with FTS5** for fast full-text search
39
+ 4. Agents search the index, then read only the files they need — minimal context
40
+
41
+ ## Commands
42
+
43
+ | Command | Description |
44
+ |---------|-------------|
45
+ | `ado-search init` | Configure organization, project, and auth |
46
+ | `ado-search sync` | Pull latest data from Azure DevOps |
47
+ | `ado-search search "query"` | Full-text search with filters |
48
+ | `ado-search show <id>` | Display full content of an item |
49
+
50
+ ## Auth Methods
51
+
52
+ - **az-cli** (default): Uses `az devops` commands. Requires `az login`.
53
+ - **az-powershell**: Uses Azure PowerShell + REST. Requires `Connect-AzAccount`.
54
+
55
+ Set via `ado-search init --auth-method az-powershell` or in `config.toml`.
56
+
57
+ ## Search Formats
58
+
59
+ ```bash
60
+ ado-search search "query" # compact (default)
61
+ ado-search search "query" --format detail # with description snippets
62
+ ado-search search "query" --format json # machine-readable
63
+ ado-search search "query" --format paths # file paths only (for agent piping)
64
+ ado-search search "query" --type Bug --state Active # filtered
65
+ ```
66
+
67
+ ## Prerequisites
68
+
69
+ - Python 3.10+
70
+ - Azure CLI with `azure-devops` extension (`az extension add --name azure-devops`)
71
+ - `az login` completed
@@ -0,0 +1,49 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "ado-search"
7
+ version = "0.1.0"
8
+ description = "Sync and search Azure DevOps work items and wiki pages for AI agents"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ authors = [{name = "Samuel Hurley"}]
13
+ keywords = ["azure-devops", "search", "work-items", "wiki", "agents"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Environment :: Console",
17
+ "Intended Audience :: Developers",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Topic :: Software Development :: Libraries",
24
+ ]
25
+ dependencies = [
26
+ "click>=8.0",
27
+ "tomli>=2.0; python_version < '3.11'",
28
+ ]
29
+
30
+ [project.urls]
31
+ Homepage = "https://github.com/HurleySk/ado-search"
32
+ Repository = "https://github.com/HurleySk/ado-search"
33
+ Issues = "https://github.com/HurleySk/ado-search/issues"
34
+
35
+ [project.optional-dependencies]
36
+ dev = [
37
+ "pytest>=7.0",
38
+ "pytest-asyncio>=0.21",
39
+ ]
40
+
41
+ [project.scripts]
42
+ ado-search = "ado_search.cli:main"
43
+
44
+ [tool.setuptools.packages.find]
45
+ where = ["src"]
46
+
47
+ [tool.pytest.ini_options]
48
+ testpaths = ["tests"]
49
+ asyncio_mode = "auto"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,159 @@
1
+ from __future__ import annotations
2
+
3
+ ADO_RESOURCE_ID = "499b84ac-1321-427f-aa17-267ca6975798"
4
+
5
+
6
+ def build_az_cli_command(
7
+ operation: str,
8
+ *,
9
+ org: str,
10
+ project: str,
11
+ wiql: str | None = None,
12
+ work_item_id: int | None = None,
13
+ wiki: str | None = None,
14
+ path: str | None = None,
15
+ url: str | None = None,
16
+ ) -> list[str]:
17
+ base = ["az"]
18
+
19
+ if operation == "query":
20
+ return [*base, "boards", "query",
21
+ "--wiql", wiql,
22
+ "--org", org, "--project", project,
23
+ "--output", "json"]
24
+
25
+ if operation == "show":
26
+ return [*base, "boards", "work-item", "show",
27
+ "--id", str(work_item_id),
28
+ "--org", org,
29
+ "--output", "json"]
30
+
31
+ if operation == "wiki-list":
32
+ return [*base, "devops", "wiki", "list",
33
+ "--org", org, "--project", project,
34
+ "--output", "json"]
35
+
36
+ if operation == "wiki-page-list":
37
+ # az devops wiki page show doesn't return subPages recursively,
38
+ # so use az rest with the wiki pages API and recursionLevel=full
39
+ # Note: & must be escaped for Windows cmd.exe batch file processing
40
+ api_url = f"{org}/{project}/_apis/wiki/wikis/{wiki}/pages"
41
+ return [*base, "rest", "--method", "get",
42
+ "--resource", ADO_RESOURCE_ID,
43
+ "--url", api_url,
44
+ "--url-parameters", "path=/", "recursionLevel=full", "api-version=7.1",
45
+ "--output", "json"]
46
+
47
+ if operation == "wiki-page-show":
48
+ api_url = f"{org}/{project}/_apis/wiki/wikis/{wiki}/pages"
49
+ return [*base, "rest", "--method", "get",
50
+ "--resource", ADO_RESOURCE_ID,
51
+ "--url", api_url,
52
+ "--url-parameters", f"path={path}", "includeContent=true", "api-version=7.1",
53
+ "--output", "json"]
54
+
55
+ if operation == "comments":
56
+ return [*base, "devops", "invoke",
57
+ "--area", "wit", "--resource", "comments",
58
+ "--route-parameters", f"id={work_item_id}",
59
+ "--org", org,
60
+ "--api-version", "7.1-preview.4",
61
+ "--output", "json"]
62
+
63
+ if operation == "odata-query":
64
+ return [*base, "rest", "--method", "get",
65
+ "--resource", ADO_RESOURCE_ID,
66
+ "--url", url,
67
+ "--output", "json"]
68
+
69
+ raise ValueError(f"Unknown operation: {operation}")
70
+
71
+
72
+ def build_powershell_command(
73
+ operation: str,
74
+ *,
75
+ org: str,
76
+ project: str,
77
+ wiql: str | None = None,
78
+ work_item_id: int | None = None,
79
+ wiki: str | None = None,
80
+ path: str | None = None,
81
+ url: str | None = None,
82
+ ) -> list[str]:
83
+ token_expr = f"(Get-AzAccessToken -ResourceUrl '{ADO_RESOURCE_ID}').Token"
84
+ headers = '@{Authorization = "Bearer $token"; "Content-Type" = "application/json"}'
85
+
86
+ safe_org = _escape_ps(org)
87
+ safe_project = _escape_ps(project)
88
+ safe_wiki = _escape_ps(wiki)
89
+
90
+ if operation == "query":
91
+ api_url = f"{safe_org}/{safe_project}/_apis/wit/wiql?api-version=7.1"
92
+ body = '{{"query": "{wiql}"}}'.replace("{wiql}", _escape_ps(wiql))
93
+ script = (
94
+ f"$token = {token_expr}; "
95
+ f"$headers = {headers}; "
96
+ f"$body = '{body}'; "
97
+ f"Invoke-RestMethod -Uri '{api_url}' -Method Post -Headers $headers -Body $body | ConvertTo-Json -Depth 10"
98
+ )
99
+ elif operation == "show":
100
+ api_url = f"{safe_org}/{safe_project}/_apis/wit/workitems/{work_item_id}?$expand=all&api-version=7.1"
101
+ script = (
102
+ f"$token = {token_expr}; "
103
+ f"$headers = {headers}; "
104
+ f"Invoke-RestMethod -Uri '{api_url}' -Method Get -Headers $headers | ConvertTo-Json -Depth 10"
105
+ )
106
+ elif operation == "wiki-list":
107
+ api_url = f"{safe_org}/{safe_project}/_apis/wiki/wikis?api-version=7.1"
108
+ script = (
109
+ f"$token = {token_expr}; "
110
+ f"$headers = {headers}; "
111
+ f"Invoke-RestMethod -Uri '{api_url}' -Method Get -Headers $headers | ConvertTo-Json -Depth 10"
112
+ )
113
+ elif operation == "wiki-page-list":
114
+ api_url = f"{safe_org}/{safe_project}/_apis/wiki/wikis/{safe_wiki}/pages?recursionLevel=full&api-version=7.1"
115
+ script = (
116
+ f"$token = {token_expr}; "
117
+ f"$headers = {headers}; "
118
+ f"Invoke-RestMethod -Uri '{api_url}' -Method Get -Headers $headers | ConvertTo-Json -Depth 10"
119
+ )
120
+ elif operation == "wiki-page-show":
121
+ safe_path = _escape_ps(path)
122
+ encoded_path = safe_path.replace("/", "%2F") if safe_path else ""
123
+ api_url = f"{safe_org}/{safe_project}/_apis/wiki/wikis/{safe_wiki}/pages?path={encoded_path}&includeContent=true&api-version=7.1"
124
+ script = (
125
+ f"$token = {token_expr}; "
126
+ f"$headers = {headers}; "
127
+ f"Invoke-RestMethod -Uri '{api_url}' -Method Get -Headers $headers | ConvertTo-Json -Depth 10"
128
+ )
129
+ elif operation == "comments":
130
+ api_url = f"{safe_org}/{safe_project}/_apis/wit/workitems/{work_item_id}/comments?api-version=7.1-preview.4"
131
+ script = (
132
+ f"$token = {token_expr}; "
133
+ f"$headers = {headers}; "
134
+ f"Invoke-RestMethod -Uri '{api_url}' -Method Get -Headers $headers | ConvertTo-Json -Depth 10"
135
+ )
136
+ elif operation == "odata-query":
137
+ safe_url = _escape_ps(url)
138
+ script = (
139
+ f"$token = {token_expr}; "
140
+ f"$headers = {headers}; "
141
+ f"Invoke-RestMethod -Uri '{safe_url}' -Method Get -Headers $headers | ConvertTo-Json -Depth 10"
142
+ )
143
+ else:
144
+ raise ValueError(f"Unknown operation: {operation}")
145
+
146
+ return ["pwsh", "-NoProfile", "-Command", script]
147
+
148
+
149
+ def _escape_ps(s: str | None) -> str:
150
+ if s is None:
151
+ return ""
152
+ return s.replace('"', '`"').replace("'", "''")
153
+
154
+
155
+ def build_command(operation: str, auth_method: str, **kwargs) -> list[str]:
156
+ """Dispatch to az-cli or PowerShell command builder based on auth method."""
157
+ if auth_method == "az-cli":
158
+ return build_az_cli_command(operation, **kwargs)
159
+ return build_powershell_command(operation, **kwargs)
@@ -0,0 +1,178 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import sys
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+
8
+ import click
9
+
10
+ from ado_search.config import default_config, load_config, save_config
11
+ from ado_search.db import Database
12
+ from ado_search.search import search, format_results
13
+
14
+
15
+ def _default_data_dir() -> Path:
16
+ return Path.cwd() / ".ado-search"
17
+
18
+
19
+ @click.group()
20
+ @click.version_option(package_name="ado-search")
21
+ def main():
22
+ """Sync and search Azure DevOps data for AI agents."""
23
+ pass
24
+
25
+
26
+ @main.command()
27
+ @click.option("--org", prompt="Organization URL", help="e.g. https://dev.azure.com/contoso")
28
+ @click.option("--project", prompt="Project name", help="Azure DevOps project name")
29
+ @click.option("--auth-method", type=click.Choice(["az-cli", "az-powershell"]),
30
+ default="az-cli", help="Authentication method")
31
+ @click.option("--data-dir", type=click.Path(), default=None,
32
+ help="Data directory (default: ./.ado-search)")
33
+ def init(org: str, project: str, auth_method: str, data_dir: str | None):
34
+ """Initialize ado-search configuration."""
35
+ data_path = Path(data_dir) if data_dir else _default_data_dir()
36
+ data_path.mkdir(parents=True, exist_ok=True)
37
+
38
+ cfg = default_config()
39
+ cfg["organization"]["url"] = org
40
+ cfg["organization"]["project"] = project
41
+ cfg["auth"]["method"] = auth_method
42
+
43
+ config_path = data_path / "config.toml"
44
+ save_config(cfg, config_path)
45
+
46
+ db = Database(data_path / "index.db")
47
+ db.initialize()
48
+ db.close()
49
+
50
+ (data_path / "work-items").mkdir(exist_ok=True)
51
+ (data_path / "wiki").mkdir(exist_ok=True)
52
+
53
+ click.echo(f"Initialized ado-search at {data_path}")
54
+ click.echo(f" Organization: {org}")
55
+ click.echo(f" Project: {project}")
56
+ click.echo(f" Auth method: {auth_method}")
57
+
58
+
59
+ @main.command()
60
+ @click.option("--data-dir", type=click.Path(exists=True), default=None)
61
+ @click.option("--dry-run", is_flag=True, help="Show what would be synced without writing")
62
+ def sync(data_dir: str | None, dry_run: bool):
63
+ """Sync work items and wiki pages from Azure DevOps."""
64
+ data_path = Path(data_dir) if data_dir else _default_data_dir()
65
+ config_path = data_path / "config.toml"
66
+
67
+ if not config_path.exists():
68
+ click.echo("Error: Not initialized. Run 'ado-search init' first.", err=True)
69
+ raise SystemExit(1)
70
+
71
+ cfg = load_config(config_path)
72
+ org = cfg["organization"]["url"]
73
+ project = cfg["organization"]["project"]
74
+ auth_method = cfg["auth"]["method"]
75
+ sync_cfg = cfg["sync"]
76
+
77
+ db = Database(data_path / "index.db")
78
+ db.initialize()
79
+
80
+ try:
81
+ from ado_search.sync_workitems import sync_work_items
82
+ from ado_search.sync_wiki import sync_wiki
83
+
84
+ click.echo("Syncing work items...")
85
+ wi_stats = asyncio.run(sync_work_items(
86
+ org=org, project=project, auth_method=auth_method,
87
+ data_dir=data_path, db=db,
88
+ work_item_types=sync_cfg.get("work_item_types", []),
89
+ area_paths=sync_cfg.get("area_paths", []),
90
+ states=sync_cfg.get("states", []),
91
+ last_sync=sync_cfg.get("last_sync", ""),
92
+ max_concurrent=sync_cfg.get("performance", {}).get("max_concurrent", 5),
93
+ dry_run=dry_run,
94
+ ))
95
+ click.echo(f" Work items: {wi_stats['fetched']} synced, {wi_stats['errors']} errors")
96
+
97
+ click.echo("Syncing wiki pages...")
98
+ wiki_stats = asyncio.run(sync_wiki(
99
+ org=org, project=project, auth_method=auth_method,
100
+ data_dir=data_path, db=db,
101
+ wiki_names=sync_cfg.get("wiki_names", []),
102
+ max_concurrent=sync_cfg.get("performance", {}).get("max_concurrent", 5),
103
+ dry_run=dry_run,
104
+ ))
105
+ click.echo(f" Wiki pages: {wiki_stats['fetched']} synced, {wiki_stats['errors']} errors")
106
+
107
+ if not dry_run:
108
+ cfg["sync"]["last_sync"] = datetime.now(timezone.utc).strftime("%Y-%m-%d")
109
+ save_config(cfg, config_path)
110
+ click.echo("Sync complete.")
111
+
112
+ finally:
113
+ db.close()
114
+
115
+
116
+ @main.command("search")
117
+ @click.argument("query")
118
+ @click.option("--type", "type_filter", default=None, help="Filter by work item type")
119
+ @click.option("--state", "state_filter", default=None, help="Filter by state")
120
+ @click.option("--area", "area_filter", default=None, help="Filter by area path (prefix match)")
121
+ @click.option("--assigned-to", default=None, help="Filter by assignee email")
122
+ @click.option("--tag", "tag_filter", default=None, help="Filter by tag")
123
+ @click.option("--limit", default=20, type=int, help="Max results (default 20)")
124
+ @click.option("--format", "fmt", type=click.Choice(["compact", "detail", "json", "paths"]),
125
+ default="compact", help="Output format")
126
+ @click.option("--data-dir", type=click.Path(), default=None)
127
+ def search_cmd(query, type_filter, state_filter, area_filter, assigned_to, tag_filter,
128
+ limit, fmt, data_dir):
129
+ """Search indexed Azure DevOps data."""
130
+ data_path = Path(data_dir) if data_dir else _default_data_dir()
131
+
132
+ if not (data_path / "index.db").exists():
133
+ click.echo("Error: No index found. Run 'ado-search init' and 'ado-search sync' first.", err=True)
134
+ raise SystemExit(1)
135
+
136
+ db = Database(data_path / "index.db")
137
+ db.initialize()
138
+
139
+ try:
140
+ results = search(
141
+ db, query, data_dir=data_path,
142
+ type_filter=type_filter, state_filter=state_filter,
143
+ area_filter=area_filter, assigned_to_filter=assigned_to,
144
+ tag_filter=tag_filter, limit=limit,
145
+ )
146
+
147
+ if not results:
148
+ click.echo(f'No results for "{query}"')
149
+ return
150
+
151
+ if fmt != "json":
152
+ click.echo(f'Results for "{query}" ({len(results)} matches):')
153
+
154
+ click.echo(format_results(results, fmt=fmt, data_dir=data_path))
155
+
156
+ finally:
157
+ db.close()
158
+
159
+
160
+ @main.command()
161
+ @click.argument("item_id")
162
+ @click.option("--data-dir", type=click.Path(), default=None)
163
+ def show(item_id: str, data_dir: str | None):
164
+ """Show full content of a work item or wiki page."""
165
+ data_path = Path(data_dir) if data_dir else _default_data_dir()
166
+
167
+ wi_path = data_path / "work-items" / f"{item_id}.md"
168
+ if wi_path.exists():
169
+ click.echo(wi_path.read_text(encoding="utf-8"))
170
+ return
171
+
172
+ wiki_path = data_path / "wiki" / f"{item_id.lstrip('/')}.md"
173
+ if wiki_path.exists():
174
+ click.echo(wiki_path.read_text(encoding="utf-8"))
175
+ return
176
+
177
+ click.echo(f"Error: Item '{item_id}' not found in work-items or wiki.", err=True)
178
+ raise SystemExit(1)
@@ -0,0 +1,71 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ if sys.version_info >= (3, 11):
7
+ import tomllib
8
+ else:
9
+ import tomli as tomllib
10
+
11
+
12
+ def default_config() -> dict:
13
+ return {
14
+ "organization": {
15
+ "url": "",
16
+ "project": "",
17
+ },
18
+ "auth": {
19
+ "method": "az-cli",
20
+ },
21
+ "sync": {
22
+ "work_item_types": ["Bug", "User Story", "Task", "Epic", "Feature"],
23
+ "area_paths": [],
24
+ "states": [],
25
+ "wiki_names": [],
26
+ "last_sync": "",
27
+ "performance": {
28
+ "max_concurrent": 5,
29
+ },
30
+ },
31
+ }
32
+
33
+
34
+ def save_config(config: dict, path: Path) -> None:
35
+ path.parent.mkdir(parents=True, exist_ok=True)
36
+ lines = _dict_to_toml(config)
37
+ path.write_text(lines, encoding="utf-8")
38
+
39
+
40
+ def load_config(path: Path) -> dict:
41
+ if not path.exists():
42
+ raise FileNotFoundError(f"Config not found: {path}")
43
+ with open(path, "rb") as f:
44
+ return tomllib.load(f)
45
+
46
+
47
+ def _dict_to_toml(d: dict, prefix: str = "") -> str:
48
+ """Minimal TOML serializer for our config structure."""
49
+ lines: list[str] = []
50
+ tables: list[tuple[str, dict]] = []
51
+
52
+ for key, value in d.items():
53
+ if isinstance(value, dict):
54
+ full_key = f"{prefix}{key}" if not prefix else f"{prefix}.{key}"
55
+ tables.append((full_key, value))
56
+ elif isinstance(value, list):
57
+ items = ", ".join(f'"{v}"' if isinstance(v, str) else str(v) for v in value)
58
+ lines.append(f"{key} = [{items}]")
59
+ elif isinstance(value, str):
60
+ lines.append(f'{key} = "{value}"')
61
+ elif isinstance(value, bool):
62
+ lines.append(f"{key} = {'true' if value else 'false'}")
63
+ elif isinstance(value, int):
64
+ lines.append(f"{key} = {value}")
65
+
66
+ result = "\n".join(lines)
67
+ for table_key, table_val in tables:
68
+ section = _dict_to_toml(table_val, prefix=table_key)
69
+ result += f"\n\n[{table_key}]\n{section}"
70
+
71
+ return result.strip() + "\n"