allye-mcp 1.0.2__py3-none-any.whl
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.
- allye_mcp/__init__.py +11 -0
- allye_mcp/application/formatters/__init__.py +40 -0
- allye_mcp/application/formatters/api_catalog.py +169 -0
- allye_mcp/application/formatters/board.py +21 -0
- allye_mcp/application/formatters/doc.py +301 -0
- allye_mcp/application/formatters/skill.py +174 -0
- allye_mcp/application/formatters/sprint.py +31 -0
- allye_mcp/application/formatters/work.py +73 -0
- allye_mcp/application/presenters.py +24 -0
- allye_mcp/application/prompt_base.py +76 -0
- allye_mcp/application/prompt_registry.py +39 -0
- allye_mcp/application/prompts/__init__.py +57 -0
- allye_mcp/application/prompts/api.py +64 -0
- allye_mcp/application/prompts/bug.py +44 -0
- allye_mcp/application/prompts/docs.py +39 -0
- allye_mcp/application/prompts/docs_create.py +42 -0
- allye_mcp/application/prompts/docs_search.py +37 -0
- allye_mcp/application/prompts/feature.py +44 -0
- allye_mcp/application/prompts/memory.py +67 -0
- allye_mcp/application/prompts/mine.py +37 -0
- allye_mcp/application/prompts/task.py +42 -0
- allye_mcp/application/prompts/todo.py +42 -0
- allye_mcp/application/prompts/todo_add.py +38 -0
- allye_mcp/application/tool_base.py +242 -0
- allye_mcp/application/tool_registry.py +39 -0
- allye_mcp/application/tools/__init__.py +41 -0
- allye_mcp/application/tools/api_catalog.py +148 -0
- allye_mcp/application/tools/boards.py +124 -0
- allye_mcp/application/tools/docs.py +395 -0
- allye_mcp/application/tools/health.py +28 -0
- allye_mcp/application/tools/initialize.py +195 -0
- allye_mcp/application/tools/intelligence.py +441 -0
- allye_mcp/application/tools/productivity.py +336 -0
- allye_mcp/application/tools/skills.py +363 -0
- allye_mcp/application/tools/sprints.py +123 -0
- allye_mcp/application/tools/team.py +173 -0
- allye_mcp/application/tools/user_config.py +183 -0
- allye_mcp/application/tools/work_items.py +590 -0
- allye_mcp/domain/_service_base.py +81 -0
- allye_mcp/domain/api_catalog.py +44 -0
- allye_mcp/domain/boards.py +182 -0
- allye_mcp/domain/docs.py +153 -0
- allye_mcp/domain/initialization.py +80 -0
- allye_mcp/domain/intelligence.py +119 -0
- allye_mcp/domain/productivity.py +212 -0
- allye_mcp/domain/skills.py +104 -0
- allye_mcp/domain/sprints.py +155 -0
- allye_mcp/domain/team.py +65 -0
- allye_mcp/domain/user_config.py +80 -0
- allye_mcp/domain/work_items.py +265 -0
- allye_mcp/infrastructure/allye_api/client.py +1341 -0
- allye_mcp/infrastructure/auth/__init__.py +26 -0
- allye_mcp/infrastructure/auth/jwks_client.py +78 -0
- allye_mcp/infrastructure/auth/jwt_validator.py +150 -0
- allye_mcp/infrastructure/auth/oauth_client_credentials.py +98 -0
- allye_mcp/infrastructure/auth/strategies.py +126 -0
- allye_mcp/infrastructure/config.py +90 -0
- allye_mcp/infrastructure/context.py +85 -0
- allye_mcp/infrastructure/http_client.py +101 -0
- allye_mcp/infrastructure/logging.py +93 -0
- allye_mcp/infrastructure/request_context.py +113 -0
- allye_mcp/interface/mcp_server.py +340 -0
- allye_mcp/interface/transports.py +394 -0
- allye_mcp/main.py +123 -0
- allye_mcp-1.0.2.dist-info/METADATA +345 -0
- allye_mcp-1.0.2.dist-info/RECORD +69 -0
- allye_mcp-1.0.2.dist-info/WHEEL +5 -0
- allye_mcp-1.0.2.dist-info/entry_points.txt +2 -0
- allye_mcp-1.0.2.dist-info/top_level.txt +1 -0
allye_mcp/__init__.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""Formatter helpers extracted from legacy monolithic tool."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from .api_catalog import _format_api_detail, _format_api_item, _format_api_search_result
|
|
7
|
+
from .board import _format_board
|
|
8
|
+
from .doc import (
|
|
9
|
+
_extract_name_count,
|
|
10
|
+
_format_doc,
|
|
11
|
+
_format_doc_analytics,
|
|
12
|
+
_format_doc_tree_flat,
|
|
13
|
+
_format_doc_tree_nested,
|
|
14
|
+
)
|
|
15
|
+
from .skill import (
|
|
16
|
+
_export_skill_format,
|
|
17
|
+
_export_skills_merged_format,
|
|
18
|
+
_format_marketplace_skill,
|
|
19
|
+
_format_skill,
|
|
20
|
+
)
|
|
21
|
+
from .sprint import _format_sprint
|
|
22
|
+
from .work import _format_work
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"_format_work",
|
|
26
|
+
"_format_board",
|
|
27
|
+
"_format_sprint",
|
|
28
|
+
"_format_doc",
|
|
29
|
+
"_format_doc_tree_flat",
|
|
30
|
+
"_format_doc_tree_nested",
|
|
31
|
+
"_extract_name_count",
|
|
32
|
+
"_format_doc_analytics",
|
|
33
|
+
"_format_skill",
|
|
34
|
+
"_format_marketplace_skill",
|
|
35
|
+
"_export_skill_format",
|
|
36
|
+
"_export_skills_merged_format",
|
|
37
|
+
"_format_api_search_result",
|
|
38
|
+
"_format_api_item",
|
|
39
|
+
"_format_api_detail",
|
|
40
|
+
]
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, List
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _format_api_search_result(api: Dict[str, Any]) -> str:
|
|
9
|
+
"""Format an API search result with similarity and matched endpoint."""
|
|
10
|
+
lines: List[str] = []
|
|
11
|
+
|
|
12
|
+
title = api.get("title", "Untitled API")
|
|
13
|
+
slug = api.get("slug", "")
|
|
14
|
+
similarity = api.get("similarity")
|
|
15
|
+
|
|
16
|
+
# Title with similarity
|
|
17
|
+
if similarity is not None:
|
|
18
|
+
pct = float(similarity) * 100
|
|
19
|
+
lines.append(f"🔗 **{title}** ({pct:.0f}% match)")
|
|
20
|
+
else:
|
|
21
|
+
lines.append(f"🔗 **{title}**")
|
|
22
|
+
|
|
23
|
+
lines.append(f"Slug: {slug}")
|
|
24
|
+
|
|
25
|
+
# Status
|
|
26
|
+
status = api.get("lifecycle_status", "unknown")
|
|
27
|
+
lines.append(f"Status: {status}")
|
|
28
|
+
|
|
29
|
+
# Description (truncated)
|
|
30
|
+
desc = api.get("description")
|
|
31
|
+
if desc:
|
|
32
|
+
truncated = desc[:150] + "..." if len(desc) > 150 else desc
|
|
33
|
+
lines.append(f"Description: {truncated}")
|
|
34
|
+
|
|
35
|
+
# Team info
|
|
36
|
+
team = api.get("team")
|
|
37
|
+
if isinstance(team, dict):
|
|
38
|
+
lines.append(f"Team: {team.get('name', 'Unknown')}")
|
|
39
|
+
|
|
40
|
+
# Matched endpoint (for semantic search)
|
|
41
|
+
matched = api.get("matchedEndpoint")
|
|
42
|
+
if matched:
|
|
43
|
+
method = matched.get("method", "").upper()
|
|
44
|
+
path = matched.get("path", "")
|
|
45
|
+
summary = matched.get("summary", "")
|
|
46
|
+
lines.append(f"Best match: **{method} {path}**")
|
|
47
|
+
if summary:
|
|
48
|
+
lines.append(f" └─ {summary}")
|
|
49
|
+
|
|
50
|
+
# Current version
|
|
51
|
+
version = api.get("current_version")
|
|
52
|
+
if isinstance(version, dict):
|
|
53
|
+
lines.append(f"Version: {version.get('version', 'N/A')}")
|
|
54
|
+
|
|
55
|
+
return "\n".join(lines)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _format_api_item(api: Dict[str, Any]) -> str:
|
|
59
|
+
"""Format an API item for listing."""
|
|
60
|
+
lines: List[str] = []
|
|
61
|
+
|
|
62
|
+
title = api.get("title", "Untitled API")
|
|
63
|
+
slug = api.get("slug", "")
|
|
64
|
+
status = api.get("lifecycle_status", "unknown")
|
|
65
|
+
|
|
66
|
+
lines.append(f"📦 **{title}**")
|
|
67
|
+
lines.append(f"ID: {api.get('id', 'N/A')}")
|
|
68
|
+
lines.append(f"Slug: {slug}")
|
|
69
|
+
lines.append(f"Status: {status}")
|
|
70
|
+
|
|
71
|
+
# Tags
|
|
72
|
+
tags = api.get("tags", [])
|
|
73
|
+
if tags:
|
|
74
|
+
lines.append(f"Tags: {', '.join(tags)}")
|
|
75
|
+
|
|
76
|
+
# Team
|
|
77
|
+
team = api.get("team")
|
|
78
|
+
if isinstance(team, dict):
|
|
79
|
+
lines.append(f"Team: {team.get('name', 'Unknown')}")
|
|
80
|
+
|
|
81
|
+
# Version
|
|
82
|
+
version = api.get("current_version")
|
|
83
|
+
if isinstance(version, dict):
|
|
84
|
+
lines.append(f"Version: {version.get('version', 'N/A')}")
|
|
85
|
+
|
|
86
|
+
return "\n".join(lines)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _format_api_detail(api: Dict[str, Any]) -> str:
|
|
90
|
+
"""Format detailed API specification view."""
|
|
91
|
+
lines: List[str] = []
|
|
92
|
+
|
|
93
|
+
title = api.get("title", "Untitled API")
|
|
94
|
+
slug = api.get("slug", "")
|
|
95
|
+
|
|
96
|
+
lines.append(f"📚 **{title}**")
|
|
97
|
+
lines.append("")
|
|
98
|
+
lines.append(f"ID: {api.get('id', 'N/A')}")
|
|
99
|
+
lines.append(f"Slug: {slug}")
|
|
100
|
+
lines.append(f"Status: {api.get('lifecycle_status', 'unknown')}")
|
|
101
|
+
|
|
102
|
+
# Description
|
|
103
|
+
desc = api.get("description")
|
|
104
|
+
if desc:
|
|
105
|
+
lines.append("")
|
|
106
|
+
lines.append("**Description:**")
|
|
107
|
+
lines.append(desc)
|
|
108
|
+
|
|
109
|
+
# Tags
|
|
110
|
+
tags = api.get("tags", [])
|
|
111
|
+
if tags:
|
|
112
|
+
lines.append("")
|
|
113
|
+
lines.append(f"Tags: {', '.join(tags)}")
|
|
114
|
+
|
|
115
|
+
# Team
|
|
116
|
+
team = api.get("team")
|
|
117
|
+
if isinstance(team, dict):
|
|
118
|
+
lines.append(f"Team: {team.get('name', 'Unknown')}")
|
|
119
|
+
|
|
120
|
+
# Current version
|
|
121
|
+
version = api.get("current_version")
|
|
122
|
+
if isinstance(version, dict):
|
|
123
|
+
lines.append("")
|
|
124
|
+
lines.append("**Current Version:**")
|
|
125
|
+
lines.append(f"- Version: {version.get('version', 'N/A')}")
|
|
126
|
+
lines.append(f"- OpenAPI: {version.get('openapi_version', 'N/A')}")
|
|
127
|
+
|
|
128
|
+
# Endpoints from current version
|
|
129
|
+
endpoints = version.get("endpoint_embeddings", [])
|
|
130
|
+
if endpoints:
|
|
131
|
+
lines.append(f"- Endpoints: {len(endpoints)}")
|
|
132
|
+
lines.append("")
|
|
133
|
+
lines.append("**Endpoints:**")
|
|
134
|
+
for ep in endpoints[:10]: # Limit to 10
|
|
135
|
+
method = ep.get("method", "").upper()
|
|
136
|
+
path = ep.get("path", "")
|
|
137
|
+
summary = ep.get("summary", "")
|
|
138
|
+
if summary:
|
|
139
|
+
lines.append(f"- **{method}** {path} - {summary}")
|
|
140
|
+
else:
|
|
141
|
+
lines.append(f"- **{method}** {path}")
|
|
142
|
+
if len(endpoints) > 10:
|
|
143
|
+
lines.append(f" ... and {len(endpoints) - 10} more endpoints")
|
|
144
|
+
|
|
145
|
+
# Environments
|
|
146
|
+
environments = api.get("environments", [])
|
|
147
|
+
if environments:
|
|
148
|
+
lines.append("")
|
|
149
|
+
lines.append("**Environments:**")
|
|
150
|
+
for env in environments:
|
|
151
|
+
name = env.get("name", "Unnamed")
|
|
152
|
+
url = env.get("base_url", "")
|
|
153
|
+
lines.append(f"- {name}: {url}")
|
|
154
|
+
|
|
155
|
+
# Permissions
|
|
156
|
+
permissions = api.get("permissions", {})
|
|
157
|
+
if permissions:
|
|
158
|
+
lines.append("")
|
|
159
|
+
lines.append("**Your Permissions:**")
|
|
160
|
+
if permissions.get("canEdit"):
|
|
161
|
+
lines.append("- ✅ Edit")
|
|
162
|
+
if permissions.get("canDelete"):
|
|
163
|
+
lines.append("- ✅ Delete")
|
|
164
|
+
if permissions.get("canPublish"):
|
|
165
|
+
lines.append("- ✅ Publish")
|
|
166
|
+
if permissions.get("canManageVersions"):
|
|
167
|
+
lines.append("- ✅ Manage Versions")
|
|
168
|
+
|
|
169
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _format_board(board: Dict[str, Any], header: Optional[str] = None) -> str:
|
|
9
|
+
lines: List[str] = []
|
|
10
|
+
if header:
|
|
11
|
+
lines.append(header)
|
|
12
|
+
lines.append("")
|
|
13
|
+
lines.extend(
|
|
14
|
+
[
|
|
15
|
+
f"🗂️ **{board.get('name', 'Unnamed')}**",
|
|
16
|
+
f"ID: {board.get('id', 'N/A')}",
|
|
17
|
+
f"Team: {board.get('team_id', 'N/A')}",
|
|
18
|
+
f"Columns: {len(board.get('columns', []))}",
|
|
19
|
+
]
|
|
20
|
+
)
|
|
21
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
from allye_mcp.domain._service_base import _format_date
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _format_doc_tree_flat(data: Any) -> str:
|
|
11
|
+
"""Format a flat list of docs (with parent_id) into a visual tree."""
|
|
12
|
+
items: List[Dict[str, Any]] = []
|
|
13
|
+
if isinstance(data, list):
|
|
14
|
+
items = data
|
|
15
|
+
elif isinstance(data, dict):
|
|
16
|
+
nested = data.get("data") or data.get("children") or data.get("items")
|
|
17
|
+
if isinstance(nested, list):
|
|
18
|
+
items = nested
|
|
19
|
+
else:
|
|
20
|
+
# Single root dict — treat as nested tree
|
|
21
|
+
return _format_doc_tree_nested(data)
|
|
22
|
+
|
|
23
|
+
if not items:
|
|
24
|
+
return "(empty)"
|
|
25
|
+
|
|
26
|
+
# Build parent→children map
|
|
27
|
+
by_id: Dict[str, Dict[str, Any]] = {}
|
|
28
|
+
children_map: Dict[Optional[str], List[str]] = {}
|
|
29
|
+
for item in items:
|
|
30
|
+
item_id = item.get("id", "")
|
|
31
|
+
by_id[item_id] = item
|
|
32
|
+
parent = item.get("parent_id")
|
|
33
|
+
children_map.setdefault(parent, []).append(item_id)
|
|
34
|
+
|
|
35
|
+
# Find roots (parent_id is None or not present in by_id)
|
|
36
|
+
root_ids = [
|
|
37
|
+
item_id
|
|
38
|
+
for item_id, item in by_id.items()
|
|
39
|
+
if item.get("parent_id") is None or item.get("parent_id") not in by_id
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
lines: List[str] = []
|
|
43
|
+
|
|
44
|
+
def _render_node(
|
|
45
|
+
item_id: str, prefix: str, connector: str, child_prefix: str
|
|
46
|
+
) -> None:
|
|
47
|
+
item = by_id.get(item_id, {})
|
|
48
|
+
emoji = item.get("emoji") or item.get("emote") or ""
|
|
49
|
+
title = item.get("title") or item.get("name") or "Untitled"
|
|
50
|
+
doc_type = item.get("doc_type") or item.get("type") or ""
|
|
51
|
+
iid = item.get("id", "")
|
|
52
|
+
|
|
53
|
+
label = f"{emoji} {title}".strip()
|
|
54
|
+
if doc_type:
|
|
55
|
+
label += f" ({doc_type})"
|
|
56
|
+
label += f" — ID: {iid}"
|
|
57
|
+
lines.append(f"{prefix}{connector}{label}")
|
|
58
|
+
|
|
59
|
+
child_ids = children_map.get(item_id, [])
|
|
60
|
+
for i, cid in enumerate(child_ids):
|
|
61
|
+
is_last_child = i == len(child_ids) - 1
|
|
62
|
+
c = "└── " if is_last_child else "├── "
|
|
63
|
+
cp = child_prefix + (" " if is_last_child else "│ ")
|
|
64
|
+
_render_node(cid, child_prefix, c, cp)
|
|
65
|
+
|
|
66
|
+
for i, rid in enumerate(root_ids):
|
|
67
|
+
_render_node(rid, "", "", "")
|
|
68
|
+
if i < len(root_ids) - 1:
|
|
69
|
+
lines.append("")
|
|
70
|
+
|
|
71
|
+
return "\n".join(lines)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _format_doc_tree_nested(data: Any) -> str:
|
|
75
|
+
"""Format a nested dict with 'children' arrays into a visual tree."""
|
|
76
|
+
if not data:
|
|
77
|
+
return "(empty)"
|
|
78
|
+
|
|
79
|
+
# If data is a list, delegate to flat formatter
|
|
80
|
+
if isinstance(data, list):
|
|
81
|
+
return _format_doc_tree_flat(data)
|
|
82
|
+
|
|
83
|
+
if not isinstance(data, dict):
|
|
84
|
+
return "(empty)"
|
|
85
|
+
|
|
86
|
+
# Unwrap if the tree is inside a 'data', 'children', or 'items' key
|
|
87
|
+
# and the root dict itself has no 'title'/'id' (i.e., it's just a wrapper)
|
|
88
|
+
if not data.get("id") and not data.get("title"):
|
|
89
|
+
nested = data.get("data") or data.get("children") or data.get("items")
|
|
90
|
+
if isinstance(nested, list):
|
|
91
|
+
return _format_doc_tree_flat(nested)
|
|
92
|
+
if isinstance(nested, dict):
|
|
93
|
+
data = nested
|
|
94
|
+
|
|
95
|
+
if not data.get("id") and not data.get("title"):
|
|
96
|
+
return "(empty)"
|
|
97
|
+
|
|
98
|
+
lines: List[str] = []
|
|
99
|
+
|
|
100
|
+
def _render(
|
|
101
|
+
node: Dict[str, Any], prefix: str, connector: str, child_prefix: str
|
|
102
|
+
) -> None:
|
|
103
|
+
emoji = node.get("emoji") or node.get("emote") or ""
|
|
104
|
+
title = node.get("title") or node.get("name") or "Untitled"
|
|
105
|
+
doc_type = node.get("doc_type") or node.get("type") or ""
|
|
106
|
+
iid = node.get("id", "")
|
|
107
|
+
|
|
108
|
+
label = f"{emoji} {title}".strip()
|
|
109
|
+
if doc_type:
|
|
110
|
+
label += f" ({doc_type})"
|
|
111
|
+
label += f" — ID: {iid}"
|
|
112
|
+
lines.append(f"{prefix}{connector}{label}")
|
|
113
|
+
|
|
114
|
+
children = node.get("children", [])
|
|
115
|
+
if isinstance(children, list):
|
|
116
|
+
valid = [c for c in children if isinstance(c, dict)]
|
|
117
|
+
for i, child in enumerate(valid):
|
|
118
|
+
is_last_child = i == len(valid) - 1
|
|
119
|
+
c = "└── " if is_last_child else "├── "
|
|
120
|
+
cp = child_prefix + (" " if is_last_child else "│ ")
|
|
121
|
+
_render(child, child_prefix, c, cp)
|
|
122
|
+
|
|
123
|
+
_render(data, "", "", "")
|
|
124
|
+
return "\n".join(lines)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _extract_name_count(item: Dict[str, Any]) -> tuple[str, Any]:
|
|
128
|
+
"""Extract a (name, count) pair from a Prisma groupBy-style dict.
|
|
129
|
+
|
|
130
|
+
Prisma groupBy returns fields like {doc_type: "page", _count: 5}
|
|
131
|
+
or {author_user_id: "uuid", _count: 3}.
|
|
132
|
+
"""
|
|
133
|
+
# Try known count keys (Prisma uses _count)
|
|
134
|
+
count = None
|
|
135
|
+
for key in ("_count", "count", "total", "value"):
|
|
136
|
+
val = item.get(key)
|
|
137
|
+
if val is not None and isinstance(val, (int, float)):
|
|
138
|
+
count = val
|
|
139
|
+
break
|
|
140
|
+
# _count can also be a dict like {author_user_id: 3}
|
|
141
|
+
raw_count = item.get("_count")
|
|
142
|
+
if count is None and isinstance(raw_count, dict):
|
|
143
|
+
for v in raw_count.values():
|
|
144
|
+
if isinstance(v, (int, float)):
|
|
145
|
+
count = v
|
|
146
|
+
break
|
|
147
|
+
|
|
148
|
+
# Try known name keys
|
|
149
|
+
name = None
|
|
150
|
+
for key in (
|
|
151
|
+
"doc_type",
|
|
152
|
+
"type",
|
|
153
|
+
"name",
|
|
154
|
+
"author",
|
|
155
|
+
"author_user_id",
|
|
156
|
+
"label",
|
|
157
|
+
"title",
|
|
158
|
+
):
|
|
159
|
+
val = item.get(key)
|
|
160
|
+
if val is not None and isinstance(val, str) and val:
|
|
161
|
+
name = val
|
|
162
|
+
break
|
|
163
|
+
|
|
164
|
+
# Fallback: first string value
|
|
165
|
+
if name is None:
|
|
166
|
+
for key, val in item.items():
|
|
167
|
+
if key.startswith("_"):
|
|
168
|
+
continue
|
|
169
|
+
if isinstance(val, str) and val:
|
|
170
|
+
name = val
|
|
171
|
+
break
|
|
172
|
+
|
|
173
|
+
return (name or "?", count if count is not None else "?")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _format_doc_analytics(analytics: Dict[str, Any]) -> str:
|
|
177
|
+
"""Format doc analytics into a readable summary."""
|
|
178
|
+
lines = ["📊 **Documentation Analytics**", ""]
|
|
179
|
+
|
|
180
|
+
# Simple scalar fields
|
|
181
|
+
simple_keys = {
|
|
182
|
+
"total",
|
|
183
|
+
"published",
|
|
184
|
+
"drafts",
|
|
185
|
+
"inReview",
|
|
186
|
+
"in_review",
|
|
187
|
+
"archived",
|
|
188
|
+
"folders",
|
|
189
|
+
"pages",
|
|
190
|
+
"guides",
|
|
191
|
+
}
|
|
192
|
+
for key, value in analytics.items():
|
|
193
|
+
if key in simple_keys and not isinstance(value, (dict, list)):
|
|
194
|
+
lines.append(f"- **{key}**: {value}")
|
|
195
|
+
|
|
196
|
+
# byType
|
|
197
|
+
by_type = analytics.get("byType")
|
|
198
|
+
if by_type is None:
|
|
199
|
+
by_type = analytics.get("by_type")
|
|
200
|
+
if isinstance(by_type, dict):
|
|
201
|
+
parts = [f"{k}: {v}" for k, v in by_type.items()]
|
|
202
|
+
if parts:
|
|
203
|
+
lines.append(f"- **By type**: {', '.join(parts)}")
|
|
204
|
+
elif isinstance(by_type, list):
|
|
205
|
+
parts = []
|
|
206
|
+
for item in by_type:
|
|
207
|
+
if isinstance(item, dict):
|
|
208
|
+
name, count = _extract_name_count(item)
|
|
209
|
+
parts.append(f"{name}: {count}")
|
|
210
|
+
if parts:
|
|
211
|
+
lines.append(f"- **By type**: {', '.join(parts)}")
|
|
212
|
+
|
|
213
|
+
# byAuthor
|
|
214
|
+
by_author = analytics.get("byAuthor")
|
|
215
|
+
if by_author is None:
|
|
216
|
+
by_author = analytics.get("by_author")
|
|
217
|
+
if isinstance(by_author, list):
|
|
218
|
+
author_parts = []
|
|
219
|
+
for item in by_author:
|
|
220
|
+
if isinstance(item, dict):
|
|
221
|
+
# Prisma groupBy returns {author_user_id, _count}
|
|
222
|
+
# Try nested author object first, then fall back to ID
|
|
223
|
+
author_obj = item.get("author")
|
|
224
|
+
if isinstance(author_obj, dict):
|
|
225
|
+
name = author_obj.get("name") or author_obj.get("email") or "?"
|
|
226
|
+
else:
|
|
227
|
+
uid = item.get("author_user_id") or ""
|
|
228
|
+
name = uid[:8] + "…" if len(uid) > 8 else uid or "?"
|
|
229
|
+
_, count = _extract_name_count(item)
|
|
230
|
+
author_parts.append(f"{name} ({count})")
|
|
231
|
+
if author_parts:
|
|
232
|
+
lines.append("")
|
|
233
|
+
lines.append("**By author:**")
|
|
234
|
+
for part in author_parts[:10]:
|
|
235
|
+
lines.append(f"- {part}")
|
|
236
|
+
if len(author_parts) > 10:
|
|
237
|
+
lines.append(f" ... and {len(author_parts) - 10} more")
|
|
238
|
+
|
|
239
|
+
# recentlyUpdated
|
|
240
|
+
recent = analytics.get("recentlyUpdated")
|
|
241
|
+
if recent is None:
|
|
242
|
+
recent = analytics.get("recently_updated")
|
|
243
|
+
if isinstance(recent, list) and recent:
|
|
244
|
+
lines.append("")
|
|
245
|
+
lines.append("**Recently updated:**")
|
|
246
|
+
for item in recent[:10]:
|
|
247
|
+
if isinstance(item, dict):
|
|
248
|
+
emoji = item.get("emoji") or item.get("emote") or ""
|
|
249
|
+
title = item.get("title") or item.get("name") or "Untitled"
|
|
250
|
+
updated = _format_date(item.get("updated_at") or item.get("updatedAt"))
|
|
251
|
+
label = f"{emoji} {title}".strip()
|
|
252
|
+
lines.append(f"- {label} ({updated})")
|
|
253
|
+
if len(recent) > 10:
|
|
254
|
+
lines.append(f" ... and {len(recent) - 10} more")
|
|
255
|
+
|
|
256
|
+
# Catch any remaining keys not yet handled
|
|
257
|
+
handled = simple_keys | {
|
|
258
|
+
"byType",
|
|
259
|
+
"by_type",
|
|
260
|
+
"byAuthor",
|
|
261
|
+
"by_author",
|
|
262
|
+
"recentlyUpdated",
|
|
263
|
+
"recently_updated",
|
|
264
|
+
}
|
|
265
|
+
for key, value in analytics.items():
|
|
266
|
+
if key not in handled and not isinstance(value, (dict, list)):
|
|
267
|
+
lines.append(f"- **{key}**: {value}")
|
|
268
|
+
|
|
269
|
+
return "\n".join(lines)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _format_doc(
|
|
273
|
+
doc: Dict[str, Any],
|
|
274
|
+
*,
|
|
275
|
+
header: Optional[str] = None,
|
|
276
|
+
show_content: bool = False,
|
|
277
|
+
) -> str:
|
|
278
|
+
lines: List[str] = []
|
|
279
|
+
if header:
|
|
280
|
+
lines.append(header)
|
|
281
|
+
lines.append("")
|
|
282
|
+
lines.extend(
|
|
283
|
+
[
|
|
284
|
+
f"📄 **{doc.get('title') or doc.get('name', 'Untitled')}**",
|
|
285
|
+
f"ID: {doc.get('id', 'N/A')}",
|
|
286
|
+
f"Status: {doc.get('status', 'N/A')}",
|
|
287
|
+
f"Team: {doc.get('team_id', 'N/A')}",
|
|
288
|
+
]
|
|
289
|
+
)
|
|
290
|
+
if doc.get("updated_at") or doc.get("updatedAt"):
|
|
291
|
+
lines.append(
|
|
292
|
+
f"Updated at: {_format_date(doc.get('updated_at') or doc.get('updatedAt'))}"
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
# Show content only when explicitly requested (e.g., doc_get)
|
|
296
|
+
if show_content and doc.get("content"):
|
|
297
|
+
lines.append("")
|
|
298
|
+
lines.append("**Content:**")
|
|
299
|
+
lines.append(str(doc.get("content") or ""))
|
|
300
|
+
|
|
301
|
+
return "\n".join(lines)
|