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.
- ado_search-0.1.0/PKG-INFO +98 -0
- ado_search-0.1.0/README.md +71 -0
- ado_search-0.1.0/pyproject.toml +49 -0
- ado_search-0.1.0/setup.cfg +4 -0
- ado_search-0.1.0/src/ado_search/__init__.py +1 -0
- ado_search-0.1.0/src/ado_search/auth.py +159 -0
- ado_search-0.1.0/src/ado_search/cli.py +178 -0
- ado_search-0.1.0/src/ado_search/config.py +71 -0
- ado_search-0.1.0/src/ado_search/db.py +225 -0
- ado_search-0.1.0/src/ado_search/markdown.py +117 -0
- ado_search-0.1.0/src/ado_search/runner.py +77 -0
- ado_search-0.1.0/src/ado_search/search.py +90 -0
- ado_search-0.1.0/src/ado_search/sync_odata.py +202 -0
- ado_search-0.1.0/src/ado_search/sync_wiki.py +188 -0
- ado_search-0.1.0/src/ado_search/sync_workitems.py +340 -0
- ado_search-0.1.0/src/ado_search.egg-info/PKG-INFO +98 -0
- ado_search-0.1.0/src/ado_search.egg-info/SOURCES.txt +30 -0
- ado_search-0.1.0/src/ado_search.egg-info/dependency_links.txt +1 -0
- ado_search-0.1.0/src/ado_search.egg-info/entry_points.txt +2 -0
- ado_search-0.1.0/src/ado_search.egg-info/requires.txt +8 -0
- ado_search-0.1.0/src/ado_search.egg-info/top_level.txt +1 -0
- ado_search-0.1.0/tests/test_auth.py +102 -0
- ado_search-0.1.0/tests/test_cli.py +46 -0
- ado_search-0.1.0/tests/test_config.py +38 -0
- ado_search-0.1.0/tests/test_db.py +242 -0
- ado_search-0.1.0/tests/test_e2e.py +75 -0
- ado_search-0.1.0/tests/test_markdown.py +62 -0
- ado_search-0.1.0/tests/test_runner.py +34 -0
- ado_search-0.1.0/tests/test_search.py +80 -0
- ado_search-0.1.0/tests/test_sync_odata.py +284 -0
- ado_search-0.1.0/tests/test_sync_wiki.py +99 -0
- 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 @@
|
|
|
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"
|