devrev-Python-SDK 2.14.0__py3-none-any.whl → 3.0.0__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.
- devrev/models/conversations.py +8 -0
- devrev/models/works.py +16 -6
- devrev/services/base.py +6 -2
- devrev/services/conversations.py +156 -0
- devrev/services/works.py +295 -10
- devrev_mcp/tools/conversations.py +77 -0
- devrev_mcp/tools/works.py +123 -7
- {devrev_python_sdk-2.14.0.dist-info → devrev_python_sdk-3.0.0.dist-info}/METADATA +1 -1
- {devrev_python_sdk-2.14.0.dist-info → devrev_python_sdk-3.0.0.dist-info}/RECORD +11 -11
- {devrev_python_sdk-2.14.0.dist-info → devrev_python_sdk-3.0.0.dist-info}/WHEEL +0 -0
- {devrev_python_sdk-2.14.0.dist-info → devrev_python_sdk-3.0.0.dist-info}/entry_points.txt +0 -0
devrev/models/conversations.py
CHANGED
|
@@ -8,6 +8,7 @@ from typing import Any
|
|
|
8
8
|
from pydantic import Field
|
|
9
9
|
|
|
10
10
|
from devrev.models.base import (
|
|
11
|
+
DateFilter,
|
|
11
12
|
DevRevBaseModel,
|
|
12
13
|
DevRevResponseModel,
|
|
13
14
|
PaginatedResponse,
|
|
@@ -63,6 +64,13 @@ class ConversationsListRequest(DevRevBaseModel):
|
|
|
63
64
|
|
|
64
65
|
cursor: str | None = Field(default=None, description="Pagination cursor")
|
|
65
66
|
limit: int | None = Field(default=None, ge=1, le=100, description="Max results")
|
|
67
|
+
modified_date: DateFilter | None = Field(
|
|
68
|
+
default=None, description="Filter by modification date"
|
|
69
|
+
)
|
|
70
|
+
sort_by: list[str] | None = Field(
|
|
71
|
+
default=None,
|
|
72
|
+
description='Sort order (e.g., ["modified_date:desc"])',
|
|
73
|
+
)
|
|
66
74
|
|
|
67
75
|
|
|
68
76
|
class ConversationsUpdateRequest(DevRevBaseModel):
|
devrev/models/works.py
CHANGED
|
@@ -167,15 +167,18 @@ class WorksListRequest(DevRevBaseModel):
|
|
|
167
167
|
type: list[WorkType] | None = Field(default=None, description="Filter by work types")
|
|
168
168
|
applies_to_part: list[str] | None = Field(default=None, description="Filter by part IDs")
|
|
169
169
|
created_by: list[str] | None = Field(default=None, description="Filter by creator user IDs")
|
|
170
|
-
created_date: DateFilter | None = Field(default=None, description="Filter by creation date")
|
|
171
170
|
cursor: str | None = Field(default=None, description="Pagination cursor")
|
|
172
171
|
external_ref: list[str] | None = Field(default=None, description="Filter by external refs")
|
|
173
172
|
limit: int | None = Field(default=None, ge=1, le=100, description="Max results to return")
|
|
174
|
-
modified_date: DateFilter | None = Field(
|
|
175
|
-
default=None, description="Filter by modification date"
|
|
176
|
-
)
|
|
177
173
|
owned_by: list[str] | None = Field(default=None, description="Filter by owner user IDs")
|
|
178
|
-
sort_by: list[str] | None = Field(
|
|
174
|
+
sort_by: list[str] | None = Field(
|
|
175
|
+
default=None,
|
|
176
|
+
description=(
|
|
177
|
+
"Sort order. Each entry uses the server form 'field:asc' or 'field:desc' "
|
|
178
|
+
"(e.g. 'modified_date:desc'). The legacy '-field' form is accepted by the "
|
|
179
|
+
"service and normalized before being sent."
|
|
180
|
+
),
|
|
181
|
+
)
|
|
179
182
|
stage_name: list[str] | None = Field(default=None, description="Filter by stage names")
|
|
180
183
|
target_close_date: DateFilter | None = Field(
|
|
181
184
|
default=None, description="Filter by target close date"
|
|
@@ -226,8 +229,15 @@ class WorksExportRequest(DevRevBaseModel):
|
|
|
226
229
|
type: list[WorkType] | None = Field(default=None, description="Filter by work types")
|
|
227
230
|
applies_to_part: list[str] | None = Field(default=None, description="Filter by part IDs")
|
|
228
231
|
created_by: list[str] | None = Field(default=None, description="Filter by creator user IDs")
|
|
229
|
-
created_date: DateFilter | None = Field(default=None, description="Filter by creation date")
|
|
230
232
|
first: int | None = Field(default=None, ge=1, le=10000, description="Max results")
|
|
233
|
+
sort_by: list[str] | None = Field(
|
|
234
|
+
default=None,
|
|
235
|
+
description=(
|
|
236
|
+
"Sort order. Each entry uses the server form 'field:asc' or 'field:desc' "
|
|
237
|
+
"(e.g. 'modified_date:desc'). The legacy '-field' form is accepted by the "
|
|
238
|
+
"service and normalized before being sent."
|
|
239
|
+
),
|
|
240
|
+
)
|
|
231
241
|
|
|
232
242
|
|
|
233
243
|
class WorksCountRequest(DevRevBaseModel):
|
devrev/services/base.py
CHANGED
|
@@ -72,7 +72,9 @@ class BaseService:
|
|
|
72
72
|
Returns:
|
|
73
73
|
Parsed response model or raw dict if no response_type
|
|
74
74
|
"""
|
|
75
|
-
data =
|
|
75
|
+
data = (
|
|
76
|
+
request.model_dump(exclude_none=True, by_alias=True, mode="json") if request else None
|
|
77
|
+
)
|
|
76
78
|
response = self._http.post(endpoint, data=data)
|
|
77
79
|
|
|
78
80
|
# Handle empty responses (204 No Content or empty body)
|
|
@@ -185,7 +187,9 @@ class AsyncBaseService:
|
|
|
185
187
|
Returns:
|
|
186
188
|
Parsed response model or raw dict if no response_type
|
|
187
189
|
"""
|
|
188
|
-
data =
|
|
190
|
+
data = (
|
|
191
|
+
request.model_dump(exclude_none=True, by_alias=True, mode="json") if request else None
|
|
192
|
+
)
|
|
189
193
|
response = await self._http.post(endpoint, data=data)
|
|
190
194
|
|
|
191
195
|
# Handle empty responses (204 No Content or empty body)
|
devrev/services/conversations.py
CHANGED
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
from collections.abc import Sequence
|
|
6
|
+
from datetime import datetime
|
|
6
7
|
from typing import overload
|
|
7
8
|
|
|
9
|
+
from devrev.models.base import DateFilter
|
|
8
10
|
from devrev.models.conversations import (
|
|
9
11
|
Conversation,
|
|
10
12
|
ConversationExportItem,
|
|
@@ -24,6 +26,68 @@ from devrev.models.conversations import (
|
|
|
24
26
|
from devrev.services.base import AsyncBaseService, BaseService
|
|
25
27
|
|
|
26
28
|
|
|
29
|
+
def _normalize_sort_by(sort_by: Sequence[str] | None) -> list[str] | None:
|
|
30
|
+
"""Normalize sort_by entries to the ``field:direction`` format.
|
|
31
|
+
|
|
32
|
+
Accepts entries already in ``field:direction`` form (e.g.
|
|
33
|
+
``"modified_date:desc"``), the legacy ``"-field"`` shorthand for
|
|
34
|
+
descending order, and bare field names (e.g. ``"modified_date"``) which
|
|
35
|
+
default to ascending order.
|
|
36
|
+
"""
|
|
37
|
+
if sort_by is None:
|
|
38
|
+
return None
|
|
39
|
+
normalized: list[str] = []
|
|
40
|
+
for entry in sort_by:
|
|
41
|
+
if ":" in entry:
|
|
42
|
+
normalized.append(entry)
|
|
43
|
+
elif entry.startswith("-"):
|
|
44
|
+
normalized.append(f"{entry[1:]}:desc")
|
|
45
|
+
else:
|
|
46
|
+
normalized.append(f"{entry}:asc")
|
|
47
|
+
return normalized
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ``ConversationsListRequest.limit`` is pydantic-constrained to ``le=100``;
|
|
51
|
+
# clamp any computed per-page limit so pagination loops do not construct a
|
|
52
|
+
# request body that fails validation before it is ever sent.
|
|
53
|
+
_CONVERSATIONS_MAX_PAGE = 100
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _resolve_page_limit(
|
|
57
|
+
overall_limit: int | None,
|
|
58
|
+
collected: int,
|
|
59
|
+
page_size: int | None,
|
|
60
|
+
) -> int | None:
|
|
61
|
+
"""Compute the ``limit`` to send for the next page request.
|
|
62
|
+
|
|
63
|
+
The returned value is always clamped to ``_CONVERSATIONS_MAX_PAGE`` so
|
|
64
|
+
callers using an ``overall_limit`` greater than the server maximum (e.g.
|
|
65
|
+
``limit=200, page_size=None``) still paginate correctly.
|
|
66
|
+
"""
|
|
67
|
+
if overall_limit is None:
|
|
68
|
+
if page_size is None:
|
|
69
|
+
return None
|
|
70
|
+
return min(page_size, _CONVERSATIONS_MAX_PAGE)
|
|
71
|
+
remaining = overall_limit - collected
|
|
72
|
+
if page_size is None:
|
|
73
|
+
return min(remaining, _CONVERSATIONS_MAX_PAGE)
|
|
74
|
+
return min(page_size, remaining, _CONVERSATIONS_MAX_PAGE)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _is_before_cutoff(modified_date: datetime | None, cutoff: datetime) -> bool:
|
|
78
|
+
"""Return True if ``modified_date`` is strictly older than ``cutoff``.
|
|
79
|
+
|
|
80
|
+
Returns False when the timestamp is unknown or when the two datetimes have
|
|
81
|
+
incompatible tz-awareness; the server-side filter remains authoritative.
|
|
82
|
+
"""
|
|
83
|
+
if modified_date is None:
|
|
84
|
+
return False
|
|
85
|
+
try:
|
|
86
|
+
return modified_date < cutoff
|
|
87
|
+
except TypeError:
|
|
88
|
+
return False
|
|
89
|
+
|
|
90
|
+
|
|
27
91
|
class ConversationsService(BaseService):
|
|
28
92
|
"""Service for managing DevRev Conversations."""
|
|
29
93
|
|
|
@@ -41,9 +105,55 @@ class ConversationsService(BaseService):
|
|
|
41
105
|
"""List conversations."""
|
|
42
106
|
if request is None:
|
|
43
107
|
request = ConversationsListRequest()
|
|
108
|
+
if request.sort_by is not None:
|
|
109
|
+
request.sort_by = _normalize_sort_by(request.sort_by)
|
|
44
110
|
response = self._post("/conversations.list", request, ConversationsListResponse)
|
|
45
111
|
return response.conversations
|
|
46
112
|
|
|
113
|
+
def list_modified_since(
|
|
114
|
+
self,
|
|
115
|
+
after: datetime,
|
|
116
|
+
*,
|
|
117
|
+
limit: int | None = None,
|
|
118
|
+
page_size: int | None = None,
|
|
119
|
+
) -> Sequence[Conversation]:
|
|
120
|
+
"""List conversations modified after a given datetime, newest first.
|
|
121
|
+
|
|
122
|
+
Streams pages via cursor until the server returns no further cursor,
|
|
123
|
+
``limit`` is reached, or a conversation older than ``after`` is seen.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
after: Only include conversations modified after this datetime.
|
|
127
|
+
limit: Maximum number of conversations to return overall.
|
|
128
|
+
page_size: Number of results per API request; ``None`` defers to server.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
List of Conversation objects modified after ``after``, newest first.
|
|
132
|
+
"""
|
|
133
|
+
results: list[Conversation] = []
|
|
134
|
+
cursor: str | None = None
|
|
135
|
+
while True:
|
|
136
|
+
if limit is not None and len(results) >= limit:
|
|
137
|
+
break
|
|
138
|
+
request_limit = _resolve_page_limit(limit, len(results), page_size)
|
|
139
|
+
request = ConversationsListRequest(
|
|
140
|
+
cursor=cursor,
|
|
141
|
+
limit=request_limit,
|
|
142
|
+
modified_date=DateFilter(after=after),
|
|
143
|
+
sort_by=_normalize_sort_by(["modified_date:desc"]),
|
|
144
|
+
)
|
|
145
|
+
response = self._post("/conversations.list", request, ConversationsListResponse)
|
|
146
|
+
for conversation in response.conversations:
|
|
147
|
+
if _is_before_cutoff(conversation.modified_date, after):
|
|
148
|
+
return results
|
|
149
|
+
results.append(conversation)
|
|
150
|
+
if limit is not None and len(results) >= limit:
|
|
151
|
+
return results
|
|
152
|
+
if not response.next_cursor:
|
|
153
|
+
break
|
|
154
|
+
cursor = response.next_cursor
|
|
155
|
+
return results
|
|
156
|
+
|
|
47
157
|
def update(self, request: ConversationsUpdateRequest) -> Conversation:
|
|
48
158
|
"""Update a conversation."""
|
|
49
159
|
response = self._post("/conversations.update", request, ConversationsUpdateResponse)
|
|
@@ -127,9 +237,55 @@ class AsyncConversationsService(AsyncBaseService):
|
|
|
127
237
|
"""List conversations."""
|
|
128
238
|
if request is None:
|
|
129
239
|
request = ConversationsListRequest()
|
|
240
|
+
if request.sort_by is not None:
|
|
241
|
+
request.sort_by = _normalize_sort_by(request.sort_by)
|
|
130
242
|
response = await self._post("/conversations.list", request, ConversationsListResponse)
|
|
131
243
|
return response.conversations
|
|
132
244
|
|
|
245
|
+
async def list_modified_since(
|
|
246
|
+
self,
|
|
247
|
+
after: datetime,
|
|
248
|
+
*,
|
|
249
|
+
limit: int | None = None,
|
|
250
|
+
page_size: int | None = None,
|
|
251
|
+
) -> Sequence[Conversation]:
|
|
252
|
+
"""List conversations modified after a given datetime, newest first.
|
|
253
|
+
|
|
254
|
+
Streams pages via cursor until the server returns no further cursor,
|
|
255
|
+
``limit`` is reached, or a conversation older than ``after`` is seen.
|
|
256
|
+
|
|
257
|
+
Args:
|
|
258
|
+
after: Only include conversations modified after this datetime.
|
|
259
|
+
limit: Maximum number of conversations to return overall.
|
|
260
|
+
page_size: Number of results per API request; ``None`` defers to server.
|
|
261
|
+
|
|
262
|
+
Returns:
|
|
263
|
+
List of Conversation objects modified after ``after``, newest first.
|
|
264
|
+
"""
|
|
265
|
+
results: list[Conversation] = []
|
|
266
|
+
cursor: str | None = None
|
|
267
|
+
while True:
|
|
268
|
+
if limit is not None and len(results) >= limit:
|
|
269
|
+
break
|
|
270
|
+
request_limit = _resolve_page_limit(limit, len(results), page_size)
|
|
271
|
+
request = ConversationsListRequest(
|
|
272
|
+
cursor=cursor,
|
|
273
|
+
limit=request_limit,
|
|
274
|
+
modified_date=DateFilter(after=after),
|
|
275
|
+
sort_by=_normalize_sort_by(["modified_date:desc"]),
|
|
276
|
+
)
|
|
277
|
+
response = await self._post("/conversations.list", request, ConversationsListResponse)
|
|
278
|
+
for conversation in response.conversations:
|
|
279
|
+
if _is_before_cutoff(conversation.modified_date, after):
|
|
280
|
+
return results
|
|
281
|
+
results.append(conversation)
|
|
282
|
+
if limit is not None and len(results) >= limit:
|
|
283
|
+
return results
|
|
284
|
+
if not response.next_cursor:
|
|
285
|
+
break
|
|
286
|
+
cursor = response.next_cursor
|
|
287
|
+
return results
|
|
288
|
+
|
|
133
289
|
async def update(self, request: ConversationsUpdateRequest) -> Conversation:
|
|
134
290
|
"""Update a conversation."""
|
|
135
291
|
response = await self._post("/conversations.update", request, ConversationsUpdateResponse)
|
devrev/services/works.py
CHANGED
|
@@ -9,7 +9,7 @@ from collections.abc import Sequence
|
|
|
9
9
|
from datetime import datetime
|
|
10
10
|
from typing import TYPE_CHECKING, Any
|
|
11
11
|
|
|
12
|
-
from devrev.models.base import
|
|
12
|
+
from devrev.models.base import StageUpdate
|
|
13
13
|
from devrev.models.works import (
|
|
14
14
|
IssuePriority,
|
|
15
15
|
TicketSeverity,
|
|
@@ -37,6 +37,74 @@ if TYPE_CHECKING:
|
|
|
37
37
|
from devrev.utils.http import AsyncHTTPClient, HTTPClient
|
|
38
38
|
|
|
39
39
|
|
|
40
|
+
# Module-level type aliases. Declared here so class-scoped annotations can
|
|
41
|
+
# refer to ``list[...]`` without colliding with the ``list`` method defined on
|
|
42
|
+
# :class:`WorksService` / :class:`AsyncWorksService`.
|
|
43
|
+
_WorkList = list[Work]
|
|
44
|
+
_StrList = list[str]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _is_before_cutoff(timestamp: datetime | None, cutoff: datetime) -> bool:
|
|
48
|
+
"""Return True if ``timestamp`` is strictly older than ``cutoff``.
|
|
49
|
+
|
|
50
|
+
Returns False when the timestamp is unknown or when the two datetimes have
|
|
51
|
+
incompatible tz-awareness; the server-side filter remains authoritative.
|
|
52
|
+
"""
|
|
53
|
+
if timestamp is None:
|
|
54
|
+
return False
|
|
55
|
+
try:
|
|
56
|
+
return timestamp < cutoff
|
|
57
|
+
except TypeError:
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ``WorksListRequest.limit`` is pydantic-constrained to ``le=100``; clamp any
|
|
62
|
+
# computed per-page limit so pagination loops do not construct a request body
|
|
63
|
+
# that fails validation before it is ever sent.
|
|
64
|
+
_WORKS_MAX_PAGE = 100
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _resolve_page_limit(
|
|
68
|
+
overall_limit: int | None,
|
|
69
|
+
collected: int,
|
|
70
|
+
page_size: int | None,
|
|
71
|
+
) -> int | None:
|
|
72
|
+
"""Compute the ``limit`` to send for the next page request.
|
|
73
|
+
|
|
74
|
+
The returned value is always clamped to ``_WORKS_MAX_PAGE`` so callers using
|
|
75
|
+
an ``overall_limit`` greater than the server maximum (e.g. ``limit=200,
|
|
76
|
+
page_size=None``) still paginate correctly.
|
|
77
|
+
"""
|
|
78
|
+
if page_size is not None:
|
|
79
|
+
return min(page_size, _WORKS_MAX_PAGE)
|
|
80
|
+
if overall_limit is None:
|
|
81
|
+
return None
|
|
82
|
+
remaining = overall_limit - collected
|
|
83
|
+
return min(remaining, _WORKS_MAX_PAGE)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _normalize_sort_by(sort_by: Sequence[str] | None) -> _StrList | None:
|
|
87
|
+
"""Normalize sort_by entries to the server-expected ``field:direction`` form.
|
|
88
|
+
|
|
89
|
+
Accepts both the legacy ``"-field"`` (descending) / ``"field"`` (ascending)
|
|
90
|
+
shorthand and the explicit ``"field:asc"`` / ``"field:desc"`` form. Returns
|
|
91
|
+
a new list with every entry in the ``"field:direction"`` form. Returns
|
|
92
|
+
``None`` when the input is ``None`` so callers can pass the result straight
|
|
93
|
+
through to request models with no-op semantics.
|
|
94
|
+
"""
|
|
95
|
+
if sort_by is None:
|
|
96
|
+
return None
|
|
97
|
+
normalized: list[str] = []
|
|
98
|
+
for entry in sort_by:
|
|
99
|
+
if ":" in entry:
|
|
100
|
+
normalized.append(entry)
|
|
101
|
+
elif entry.startswith("-"):
|
|
102
|
+
normalized.append(f"{entry[1:]}:desc")
|
|
103
|
+
else:
|
|
104
|
+
normalized.append(f"{entry}:asc")
|
|
105
|
+
return normalized
|
|
106
|
+
|
|
107
|
+
|
|
40
108
|
class WorksService(BaseService):
|
|
41
109
|
"""Synchronous service for managing DevRev work items.
|
|
42
110
|
|
|
@@ -109,10 +177,10 @@ class WorksService(BaseService):
|
|
|
109
177
|
type: Sequence[WorkType] | None = None,
|
|
110
178
|
applies_to_part: Sequence[str] | None = None,
|
|
111
179
|
created_by: Sequence[str] | None = None,
|
|
112
|
-
created_date: DateFilter | None = None,
|
|
113
180
|
cursor: str | None = None,
|
|
114
181
|
limit: int | None = None,
|
|
115
182
|
owned_by: Sequence[str] | None = None,
|
|
183
|
+
sort_by: Sequence[str] | None = None,
|
|
116
184
|
stage_name: Sequence[str] | None = None,
|
|
117
185
|
) -> WorksListResponse:
|
|
118
186
|
"""List work items.
|
|
@@ -121,10 +189,12 @@ class WorksService(BaseService):
|
|
|
121
189
|
type: Filter by work types
|
|
122
190
|
applies_to_part: Filter by part IDs
|
|
123
191
|
created_by: Filter by creator user IDs
|
|
124
|
-
created_date: Filter by creation date
|
|
125
192
|
cursor: Pagination cursor
|
|
126
193
|
limit: Maximum number of results
|
|
127
194
|
owned_by: Filter by owner user IDs
|
|
195
|
+
sort_by: Sort order. Accepts either the server form
|
|
196
|
+
``"field:asc"`` / ``"field:desc"`` or the legacy
|
|
197
|
+
``"-field"`` shorthand; the client normalizes before sending.
|
|
128
198
|
stage_name: Filter by stage names
|
|
129
199
|
|
|
130
200
|
Returns:
|
|
@@ -134,10 +204,10 @@ class WorksService(BaseService):
|
|
|
134
204
|
type=type,
|
|
135
205
|
applies_to_part=applies_to_part,
|
|
136
206
|
created_by=created_by,
|
|
137
|
-
created_date=created_date,
|
|
138
207
|
cursor=cursor,
|
|
139
208
|
limit=limit,
|
|
140
209
|
owned_by=owned_by,
|
|
210
|
+
sort_by=_normalize_sort_by(sort_by),
|
|
141
211
|
stage_name=stage_name,
|
|
142
212
|
)
|
|
143
213
|
return self._post("/works.list", request, WorksListResponse)
|
|
@@ -198,8 +268,8 @@ class WorksService(BaseService):
|
|
|
198
268
|
type: Sequence[WorkType] | None = None,
|
|
199
269
|
applies_to_part: Sequence[str] | None = None,
|
|
200
270
|
created_by: Sequence[str] | None = None,
|
|
201
|
-
created_date: DateFilter | None = None,
|
|
202
271
|
first: int | None = None,
|
|
272
|
+
sort_by: Sequence[str] | None = None,
|
|
203
273
|
) -> Sequence[Work]:
|
|
204
274
|
"""Export work items.
|
|
205
275
|
|
|
@@ -207,8 +277,10 @@ class WorksService(BaseService):
|
|
|
207
277
|
type: Filter by work types
|
|
208
278
|
applies_to_part: Filter by part IDs
|
|
209
279
|
created_by: Filter by creator user IDs
|
|
210
|
-
created_date: Filter by creation date
|
|
211
280
|
first: Maximum number of results
|
|
281
|
+
sort_by: Sort order. Accepts either the server form
|
|
282
|
+
``"field:asc"`` / ``"field:desc"`` or the legacy
|
|
283
|
+
``"-field"`` shorthand; the client normalizes before sending.
|
|
212
284
|
|
|
213
285
|
Returns:
|
|
214
286
|
List of exported work items
|
|
@@ -217,8 +289,8 @@ class WorksService(BaseService):
|
|
|
217
289
|
type=type,
|
|
218
290
|
applies_to_part=applies_to_part,
|
|
219
291
|
created_by=created_by,
|
|
220
|
-
created_date=created_date,
|
|
221
292
|
first=first,
|
|
293
|
+
sort_by=_normalize_sort_by(sort_by),
|
|
222
294
|
)
|
|
223
295
|
response = self._post("/works.export", request, WorksExportResponse)
|
|
224
296
|
return response.works
|
|
@@ -251,6 +323,104 @@ class WorksService(BaseService):
|
|
|
251
323
|
response = self._post("/works.count", request, WorksCountResponse)
|
|
252
324
|
return response.count
|
|
253
325
|
|
|
326
|
+
def _list_since(
|
|
327
|
+
self,
|
|
328
|
+
after: datetime,
|
|
329
|
+
timestamp_field: str,
|
|
330
|
+
*,
|
|
331
|
+
type: Sequence[WorkType] | None,
|
|
332
|
+
owned_by: Sequence[str] | None,
|
|
333
|
+
applies_to_part: Sequence[str] | None,
|
|
334
|
+
limit: int | None,
|
|
335
|
+
page_size: int | None,
|
|
336
|
+
) -> _WorkList:
|
|
337
|
+
"""Shared cursor-paginated fetcher for ``list_*_since`` helpers.
|
|
338
|
+
|
|
339
|
+
Streams pages sorted ``{timestamp_field}:desc`` and early-exits as soon
|
|
340
|
+
as a record's timestamp is strictly older than ``after``. Respects
|
|
341
|
+
``limit`` as a hard cap on returned items.
|
|
342
|
+
"""
|
|
343
|
+
sort_by = [f"{timestamp_field}:desc"]
|
|
344
|
+
collected: _WorkList = []
|
|
345
|
+
cursor: str | None = None
|
|
346
|
+
while True:
|
|
347
|
+
if limit is not None and len(collected) >= limit:
|
|
348
|
+
break
|
|
349
|
+
page = self.list(
|
|
350
|
+
type=type,
|
|
351
|
+
owned_by=owned_by,
|
|
352
|
+
applies_to_part=applies_to_part,
|
|
353
|
+
cursor=cursor,
|
|
354
|
+
limit=_resolve_page_limit(limit, len(collected), page_size),
|
|
355
|
+
sort_by=sort_by,
|
|
356
|
+
)
|
|
357
|
+
stop = False
|
|
358
|
+
for work in page.works:
|
|
359
|
+
timestamp = getattr(work, timestamp_field, None)
|
|
360
|
+
if _is_before_cutoff(timestamp, after):
|
|
361
|
+
stop = True
|
|
362
|
+
break
|
|
363
|
+
collected.append(work)
|
|
364
|
+
if limit is not None and len(collected) >= limit:
|
|
365
|
+
stop = True
|
|
366
|
+
break
|
|
367
|
+
if stop or not page.next_cursor:
|
|
368
|
+
break
|
|
369
|
+
cursor = page.next_cursor
|
|
370
|
+
return collected
|
|
371
|
+
|
|
372
|
+
def list_modified_since(
|
|
373
|
+
self,
|
|
374
|
+
after: datetime,
|
|
375
|
+
*,
|
|
376
|
+
type: Sequence[WorkType] | None = None,
|
|
377
|
+
owned_by: Sequence[str] | None = None,
|
|
378
|
+
applies_to_part: Sequence[str] | None = None,
|
|
379
|
+
limit: int | None = None,
|
|
380
|
+
page_size: int | None = None,
|
|
381
|
+
) -> _WorkList:
|
|
382
|
+
"""Return work items modified at or after ``after``.
|
|
383
|
+
|
|
384
|
+
Pages through ``works.list`` sorted by ``modified_date:desc`` and stops
|
|
385
|
+
as soon as it sees a record older than ``after``. Uses ``limit`` as a
|
|
386
|
+
hard cap when provided.
|
|
387
|
+
"""
|
|
388
|
+
return self._list_since(
|
|
389
|
+
after,
|
|
390
|
+
"modified_date",
|
|
391
|
+
type=type,
|
|
392
|
+
owned_by=owned_by,
|
|
393
|
+
applies_to_part=applies_to_part,
|
|
394
|
+
limit=limit,
|
|
395
|
+
page_size=page_size,
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
def list_created_since(
|
|
399
|
+
self,
|
|
400
|
+
after: datetime,
|
|
401
|
+
*,
|
|
402
|
+
type: Sequence[WorkType] | None = None,
|
|
403
|
+
owned_by: Sequence[str] | None = None,
|
|
404
|
+
applies_to_part: Sequence[str] | None = None,
|
|
405
|
+
limit: int | None = None,
|
|
406
|
+
page_size: int | None = None,
|
|
407
|
+
) -> _WorkList:
|
|
408
|
+
"""Return work items created at or after ``after``.
|
|
409
|
+
|
|
410
|
+
Pages through ``works.list`` sorted by ``created_date:desc`` and stops
|
|
411
|
+
as soon as it sees a record older than ``after``. Uses ``limit`` as a
|
|
412
|
+
hard cap when provided.
|
|
413
|
+
"""
|
|
414
|
+
return self._list_since(
|
|
415
|
+
after,
|
|
416
|
+
"created_date",
|
|
417
|
+
type=type,
|
|
418
|
+
owned_by=owned_by,
|
|
419
|
+
applies_to_part=applies_to_part,
|
|
420
|
+
limit=limit,
|
|
421
|
+
page_size=page_size,
|
|
422
|
+
)
|
|
423
|
+
|
|
254
424
|
|
|
255
425
|
class AsyncWorksService(AsyncBaseService):
|
|
256
426
|
"""Asynchronous service for managing DevRev work items."""
|
|
@@ -301,14 +471,21 @@ class AsyncWorksService(AsyncBaseService):
|
|
|
301
471
|
cursor: str | None = None,
|
|
302
472
|
limit: int | None = None,
|
|
303
473
|
owned_by: Sequence[str] | None = None,
|
|
474
|
+
sort_by: Sequence[str] | None = None,
|
|
304
475
|
) -> WorksListResponse:
|
|
305
|
-
"""List work items.
|
|
476
|
+
"""List work items.
|
|
477
|
+
|
|
478
|
+
``sort_by`` accepts either the server form ``"field:asc"`` /
|
|
479
|
+
``"field:desc"`` or the legacy ``"-field"`` shorthand; the client
|
|
480
|
+
normalizes before sending.
|
|
481
|
+
"""
|
|
306
482
|
request = WorksListRequest(
|
|
307
483
|
type=type,
|
|
308
484
|
applies_to_part=applies_to_part,
|
|
309
485
|
cursor=cursor,
|
|
310
486
|
limit=limit,
|
|
311
487
|
owned_by=owned_by,
|
|
488
|
+
sort_by=_normalize_sort_by(sort_by),
|
|
312
489
|
)
|
|
313
490
|
return await self._post("/works.list", request, WorksListResponse)
|
|
314
491
|
|
|
@@ -345,9 +522,19 @@ class AsyncWorksService(AsyncBaseService):
|
|
|
345
522
|
*,
|
|
346
523
|
type: Sequence[WorkType] | None = None,
|
|
347
524
|
first: int | None = None,
|
|
525
|
+
sort_by: Sequence[str] | None = None,
|
|
348
526
|
) -> Sequence[Work]:
|
|
349
|
-
"""Export work items.
|
|
350
|
-
|
|
527
|
+
"""Export work items.
|
|
528
|
+
|
|
529
|
+
``sort_by`` accepts either the server form ``"field:asc"`` /
|
|
530
|
+
``"field:desc"`` or the legacy ``"-field"`` shorthand; the client
|
|
531
|
+
normalizes before sending.
|
|
532
|
+
"""
|
|
533
|
+
request = WorksExportRequest(
|
|
534
|
+
type=type,
|
|
535
|
+
first=first,
|
|
536
|
+
sort_by=_normalize_sort_by(sort_by),
|
|
537
|
+
)
|
|
351
538
|
response = await self._post("/works.export", request, WorksExportResponse)
|
|
352
539
|
return response.works
|
|
353
540
|
|
|
@@ -361,3 +548,101 @@ class AsyncWorksService(AsyncBaseService):
|
|
|
361
548
|
request = WorksCountRequest(type=type, owned_by=owned_by)
|
|
362
549
|
response = await self._post("/works.count", request, WorksCountResponse)
|
|
363
550
|
return response.count
|
|
551
|
+
|
|
552
|
+
async def _list_since(
|
|
553
|
+
self,
|
|
554
|
+
after: datetime,
|
|
555
|
+
timestamp_field: str,
|
|
556
|
+
*,
|
|
557
|
+
type: Sequence[WorkType] | None,
|
|
558
|
+
owned_by: Sequence[str] | None,
|
|
559
|
+
applies_to_part: Sequence[str] | None,
|
|
560
|
+
limit: int | None,
|
|
561
|
+
page_size: int | None,
|
|
562
|
+
) -> _WorkList:
|
|
563
|
+
"""Shared cursor-paginated fetcher for async ``list_*_since`` helpers.
|
|
564
|
+
|
|
565
|
+
Streams pages sorted ``{timestamp_field}:desc`` and early-exits as soon
|
|
566
|
+
as a record's timestamp is strictly older than ``after``. Respects
|
|
567
|
+
``limit`` as a hard cap on returned items.
|
|
568
|
+
"""
|
|
569
|
+
sort_by = [f"{timestamp_field}:desc"]
|
|
570
|
+
collected: _WorkList = []
|
|
571
|
+
cursor: str | None = None
|
|
572
|
+
while True:
|
|
573
|
+
if limit is not None and len(collected) >= limit:
|
|
574
|
+
break
|
|
575
|
+
page = await self.list(
|
|
576
|
+
type=type,
|
|
577
|
+
owned_by=owned_by,
|
|
578
|
+
applies_to_part=applies_to_part,
|
|
579
|
+
cursor=cursor,
|
|
580
|
+
limit=_resolve_page_limit(limit, len(collected), page_size),
|
|
581
|
+
sort_by=sort_by,
|
|
582
|
+
)
|
|
583
|
+
stop = False
|
|
584
|
+
for work in page.works:
|
|
585
|
+
timestamp = getattr(work, timestamp_field, None)
|
|
586
|
+
if _is_before_cutoff(timestamp, after):
|
|
587
|
+
stop = True
|
|
588
|
+
break
|
|
589
|
+
collected.append(work)
|
|
590
|
+
if limit is not None and len(collected) >= limit:
|
|
591
|
+
stop = True
|
|
592
|
+
break
|
|
593
|
+
if stop or not page.next_cursor:
|
|
594
|
+
break
|
|
595
|
+
cursor = page.next_cursor
|
|
596
|
+
return collected
|
|
597
|
+
|
|
598
|
+
async def list_modified_since(
|
|
599
|
+
self,
|
|
600
|
+
after: datetime,
|
|
601
|
+
*,
|
|
602
|
+
type: Sequence[WorkType] | None = None,
|
|
603
|
+
owned_by: Sequence[str] | None = None,
|
|
604
|
+
applies_to_part: Sequence[str] | None = None,
|
|
605
|
+
limit: int | None = None,
|
|
606
|
+
page_size: int | None = None,
|
|
607
|
+
) -> _WorkList:
|
|
608
|
+
"""Return work items modified at or after ``after``.
|
|
609
|
+
|
|
610
|
+
Pages through ``works.list`` sorted by ``modified_date:desc`` and stops
|
|
611
|
+
as soon as it sees a record older than ``after``. Uses ``limit`` as a
|
|
612
|
+
hard cap when provided.
|
|
613
|
+
"""
|
|
614
|
+
return await self._list_since(
|
|
615
|
+
after,
|
|
616
|
+
"modified_date",
|
|
617
|
+
type=type,
|
|
618
|
+
owned_by=owned_by,
|
|
619
|
+
applies_to_part=applies_to_part,
|
|
620
|
+
limit=limit,
|
|
621
|
+
page_size=page_size,
|
|
622
|
+
)
|
|
623
|
+
|
|
624
|
+
async def list_created_since(
|
|
625
|
+
self,
|
|
626
|
+
after: datetime,
|
|
627
|
+
*,
|
|
628
|
+
type: Sequence[WorkType] | None = None,
|
|
629
|
+
owned_by: Sequence[str] | None = None,
|
|
630
|
+
applies_to_part: Sequence[str] | None = None,
|
|
631
|
+
limit: int | None = None,
|
|
632
|
+
page_size: int | None = None,
|
|
633
|
+
) -> _WorkList:
|
|
634
|
+
"""Return work items created at or after ``after``.
|
|
635
|
+
|
|
636
|
+
Pages through ``works.list`` sorted by ``created_date:desc`` and stops
|
|
637
|
+
as soon as it sees a record older than ``after``. Uses ``limit`` as a
|
|
638
|
+
hard cap when provided.
|
|
639
|
+
"""
|
|
640
|
+
return await self._list_since(
|
|
641
|
+
after,
|
|
642
|
+
"created_date",
|
|
643
|
+
type=type,
|
|
644
|
+
owned_by=owned_by,
|
|
645
|
+
applies_to_part=applies_to_part,
|
|
646
|
+
limit=limit,
|
|
647
|
+
page_size=page_size,
|
|
648
|
+
)
|
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import logging
|
|
6
|
+
from datetime import datetime
|
|
6
7
|
from typing import Any
|
|
7
8
|
|
|
8
9
|
from mcp.server.fastmcp import Context
|
|
9
10
|
|
|
10
11
|
from devrev.exceptions import DevRevError
|
|
12
|
+
from devrev.models.base import DateFilter
|
|
11
13
|
from devrev.models.conversations import (
|
|
12
14
|
ConversationsCreateRequest,
|
|
13
15
|
ConversationsDeleteRequest,
|
|
@@ -24,25 +26,71 @@ from devrev_mcp.utils.pagination import clamp_page_size, paginated_response
|
|
|
24
26
|
logger = logging.getLogger(__name__)
|
|
25
27
|
|
|
26
28
|
|
|
29
|
+
def _parse_iso_datetime(value: str, field_name: str) -> datetime:
|
|
30
|
+
"""Parse an ISO-8601 datetime string, accepting a trailing ``Z`` for UTC.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
value: The ISO-8601 datetime string.
|
|
34
|
+
field_name: Name of the parameter being parsed (for error messages).
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
A parsed ``datetime`` instance.
|
|
38
|
+
|
|
39
|
+
Raises:
|
|
40
|
+
RuntimeError: If the string cannot be parsed.
|
|
41
|
+
"""
|
|
42
|
+
try:
|
|
43
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
44
|
+
except ValueError as e:
|
|
45
|
+
raise RuntimeError(
|
|
46
|
+
f"Invalid {field_name} format: {value}. "
|
|
47
|
+
"Use ISO-8601 format (e.g., 2025-01-01T00:00:00Z)."
|
|
48
|
+
) from e
|
|
49
|
+
|
|
50
|
+
|
|
27
51
|
@mcp.tool()
|
|
28
52
|
async def devrev_conversations_list(
|
|
29
53
|
ctx: Context[Any, Any, Any],
|
|
30
54
|
cursor: str | None = None,
|
|
31
55
|
limit: int | None = None,
|
|
56
|
+
modified_date_after: str | None = None,
|
|
57
|
+
modified_date_before: str | None = None,
|
|
58
|
+
sort_by: list[str] | None = None,
|
|
32
59
|
) -> dict[str, Any]:
|
|
33
60
|
"""List DevRev conversations.
|
|
34
61
|
|
|
35
62
|
Args:
|
|
36
63
|
cursor: Pagination cursor from a previous response.
|
|
37
64
|
limit: Maximum number of items to return (default: 25, max: 100).
|
|
65
|
+
modified_date_after: Only include conversations modified after this
|
|
66
|
+
ISO-8601 timestamp (e.g., "2025-01-01T00:00:00Z").
|
|
67
|
+
modified_date_before: Only include conversations modified before this
|
|
68
|
+
ISO-8601 timestamp.
|
|
69
|
+
sort_by: Sort order entries (e.g., ["modified_date:desc"] or
|
|
70
|
+
["-modified_date"]).
|
|
38
71
|
"""
|
|
39
72
|
app = ctx.request_context.lifespan_context
|
|
40
73
|
try:
|
|
74
|
+
modified_date: DateFilter | None = None
|
|
75
|
+
if modified_date_after is not None or modified_date_before is not None:
|
|
76
|
+
after_dt = (
|
|
77
|
+
_parse_iso_datetime(modified_date_after, "modified_date_after")
|
|
78
|
+
if modified_date_after is not None
|
|
79
|
+
else None
|
|
80
|
+
)
|
|
81
|
+
before_dt = (
|
|
82
|
+
_parse_iso_datetime(modified_date_before, "modified_date_before")
|
|
83
|
+
if modified_date_before is not None
|
|
84
|
+
else None
|
|
85
|
+
)
|
|
86
|
+
modified_date = DateFilter(after=after_dt, before=before_dt)
|
|
41
87
|
request = ConversationsListRequest(
|
|
42
88
|
cursor=cursor,
|
|
43
89
|
limit=clamp_page_size(
|
|
44
90
|
limit, default=app.config.default_page_size, maximum=app.config.max_page_size
|
|
45
91
|
),
|
|
92
|
+
modified_date=modified_date,
|
|
93
|
+
sort_by=sort_by,
|
|
46
94
|
)
|
|
47
95
|
conversations = await app.get_client().conversations.list(request)
|
|
48
96
|
items = serialize_models(list(conversations))
|
|
@@ -51,6 +99,35 @@ async def devrev_conversations_list(
|
|
|
51
99
|
raise RuntimeError(format_devrev_error(e)) from e
|
|
52
100
|
|
|
53
101
|
|
|
102
|
+
@mcp.tool()
|
|
103
|
+
async def devrev_conversations_list_modified_since(
|
|
104
|
+
ctx: Context[Any, Any, Any],
|
|
105
|
+
after: str,
|
|
106
|
+
limit: int | None = None,
|
|
107
|
+
) -> dict[str, Any]:
|
|
108
|
+
"""List DevRev conversations modified after a given ISO-8601 datetime.
|
|
109
|
+
|
|
110
|
+
Streams pages newest-first, stopping when the cutoff is crossed or the
|
|
111
|
+
``limit`` is reached. The cutoff is supplied as an ISO-8601 string.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
after: ISO-8601 datetime; only conversations modified after this are
|
|
115
|
+
returned (e.g., "2025-01-01T00:00:00Z").
|
|
116
|
+
limit: Maximum number of conversations to return overall.
|
|
117
|
+
"""
|
|
118
|
+
app = ctx.request_context.lifespan_context
|
|
119
|
+
after_dt = _parse_iso_datetime(after, "after")
|
|
120
|
+
try:
|
|
121
|
+
conversations = await app.get_client().conversations.list_modified_since(
|
|
122
|
+
after=after_dt,
|
|
123
|
+
limit=limit,
|
|
124
|
+
)
|
|
125
|
+
items = serialize_models(list(conversations))
|
|
126
|
+
return {"count": len(items), "conversations": items}
|
|
127
|
+
except DevRevError as e:
|
|
128
|
+
raise RuntimeError(format_devrev_error(e)) from e
|
|
129
|
+
|
|
130
|
+
|
|
54
131
|
@mcp.tool()
|
|
55
132
|
async def devrev_conversations_get(
|
|
56
133
|
ctx: Context[Any, Any, Any],
|
devrev_mcp/tools/works.py
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import logging
|
|
6
|
+
from datetime import datetime
|
|
6
7
|
from typing import Any
|
|
7
8
|
|
|
8
9
|
from mcp.server.fastmcp import Context
|
|
@@ -18,6 +19,17 @@ from devrev_mcp.utils.pagination import clamp_page_size, paginated_response
|
|
|
18
19
|
logger = logging.getLogger(__name__)
|
|
19
20
|
|
|
20
21
|
|
|
22
|
+
def _parse_iso_after(value: str, param_name: str) -> datetime:
|
|
23
|
+
"""Parse an ISO-8601 timestamp, accepting a trailing ``Z`` for UTC."""
|
|
24
|
+
try:
|
|
25
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
26
|
+
except ValueError as e:
|
|
27
|
+
raise RuntimeError(
|
|
28
|
+
f"Invalid {param_name} format: {value}. "
|
|
29
|
+
"Use ISO-8601 (e.g., 2024-01-15T00:00:00Z or 2024-01-15T00:00:00+00:00)."
|
|
30
|
+
) from e
|
|
31
|
+
|
|
32
|
+
|
|
21
33
|
@mcp.tool()
|
|
22
34
|
async def devrev_works_list(
|
|
23
35
|
ctx: Context[Any, Any, Any],
|
|
@@ -26,8 +38,12 @@ async def devrev_works_list(
|
|
|
26
38
|
owned_by: list[str] | None = None,
|
|
27
39
|
cursor: str | None = None,
|
|
28
40
|
limit: int | None = None,
|
|
41
|
+
sort_by: list[str] | None = None,
|
|
29
42
|
) -> dict[str, Any]:
|
|
30
|
-
"""List DevRev work items (tickets, issues, tasks).
|
|
43
|
+
"""List DevRev work items (tickets, issues, and tasks).
|
|
44
|
+
|
|
45
|
+
Work items in DevRev include tickets (customer-facing), issues (internal
|
|
46
|
+
engineering), and tasks. Use the ``type`` filter to scope results.
|
|
31
47
|
|
|
32
48
|
Args:
|
|
33
49
|
type: Filter by work type(s): TICKET, ISSUE, TASK, OPPORTUNITY.
|
|
@@ -35,6 +51,8 @@ async def devrev_works_list(
|
|
|
35
51
|
owned_by: Filter by owner user ID(s).
|
|
36
52
|
cursor: Pagination cursor from a previous response.
|
|
37
53
|
limit: Maximum number of items to return (default: 25, max: 100).
|
|
54
|
+
sort_by: Sort order as a list of ``"field:asc"`` / ``"field:desc"``
|
|
55
|
+
entries (or legacy ``"-field"`` shorthand), forwarded to the SDK.
|
|
38
56
|
"""
|
|
39
57
|
app = ctx.request_context.lifespan_context
|
|
40
58
|
try:
|
|
@@ -56,6 +74,7 @@ async def devrev_works_list(
|
|
|
56
74
|
limit=clamp_page_size(
|
|
57
75
|
limit, default=app.config.default_page_size, maximum=app.config.max_page_size
|
|
58
76
|
),
|
|
77
|
+
sort_by=sort_by,
|
|
59
78
|
)
|
|
60
79
|
items = serialize_models(response.works)
|
|
61
80
|
return paginated_response(items, next_cursor=response.next_cursor, total_label="works")
|
|
@@ -68,7 +87,7 @@ async def devrev_works_get(
|
|
|
68
87
|
ctx: Context[Any, Any, Any],
|
|
69
88
|
id: str,
|
|
70
89
|
) -> dict[str, Any]:
|
|
71
|
-
"""Get a DevRev work item by ID.
|
|
90
|
+
"""Get a DevRev work item (ticket, issue, or task) by ID.
|
|
72
91
|
|
|
73
92
|
Args:
|
|
74
93
|
id: The work item ID (e.g., don:core:dvrv-us-1:devo/1:issue/123).
|
|
@@ -161,7 +180,7 @@ if _config.enable_destructive_tools:
|
|
|
161
180
|
priority: str | None = None,
|
|
162
181
|
severity: str | None = None,
|
|
163
182
|
) -> dict[str, Any]:
|
|
164
|
-
"""Update an existing DevRev work item.
|
|
183
|
+
"""Update an existing DevRev work item (ticket, issue, or task).
|
|
165
184
|
|
|
166
185
|
Only provided fields will be updated; others remain unchanged.
|
|
167
186
|
|
|
@@ -214,7 +233,7 @@ if _config.enable_destructive_tools:
|
|
|
214
233
|
ctx: Context[Any, Any, Any],
|
|
215
234
|
id: str,
|
|
216
235
|
) -> dict[str, Any]:
|
|
217
|
-
"""Delete a DevRev work item.
|
|
236
|
+
"""Delete a DevRev work item (ticket, issue, or task).
|
|
218
237
|
|
|
219
238
|
Args:
|
|
220
239
|
id: The work item ID to delete.
|
|
@@ -234,7 +253,7 @@ async def devrev_works_count(
|
|
|
234
253
|
type: list[str] | None = None,
|
|
235
254
|
owned_by: list[str] | None = None,
|
|
236
255
|
) -> dict[str, Any]:
|
|
237
|
-
"""Count DevRev work items matching filters.
|
|
256
|
+
"""Count DevRev work items (tickets, issues, and tasks) matching filters.
|
|
238
257
|
|
|
239
258
|
Args:
|
|
240
259
|
type: Filter by work type(s): TICKET, ISSUE, TASK, OPPORTUNITY.
|
|
@@ -263,14 +282,17 @@ async def devrev_works_export(
|
|
|
263
282
|
ctx: Context[Any, Any, Any],
|
|
264
283
|
type: list[str] | None = None,
|
|
265
284
|
first: int | None = None,
|
|
285
|
+
sort_by: list[str] | None = None,
|
|
266
286
|
) -> dict[str, Any]:
|
|
267
|
-
"""Export DevRev work items.
|
|
287
|
+
"""Export DevRev work items (tickets, issues, and tasks).
|
|
268
288
|
|
|
269
289
|
Returns a bulk export of work items. Use for large data retrieval.
|
|
270
290
|
|
|
271
291
|
Args:
|
|
272
292
|
type: Filter by work type(s): TICKET, ISSUE, TASK, OPPORTUNITY.
|
|
273
293
|
first: Maximum number of items to export.
|
|
294
|
+
sort_by: Sort order as a list of ``"field:asc"`` / ``"field:desc"``
|
|
295
|
+
entries (or legacy ``"-field"`` shorthand), forwarded to the SDK.
|
|
274
296
|
"""
|
|
275
297
|
app = ctx.request_context.lifespan_context
|
|
276
298
|
try:
|
|
@@ -284,8 +306,102 @@ async def devrev_works_export(
|
|
|
284
306
|
f"Invalid work type: {e.args[0]}. "
|
|
285
307
|
f"Valid types: {', '.join(wt.name for wt in WorkType)}"
|
|
286
308
|
) from e
|
|
287
|
-
works = await app.get_client().works.export(type=work_types, first=first)
|
|
309
|
+
works = await app.get_client().works.export(type=work_types, first=first, sort_by=sort_by)
|
|
288
310
|
items = serialize_models(list(works))
|
|
289
311
|
return {"count": len(items), "works": items}
|
|
290
312
|
except DevRevError as e:
|
|
291
313
|
raise RuntimeError(format_devrev_error(e)) from e
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
@mcp.tool()
|
|
317
|
+
async def devrev_works_list_modified_since(
|
|
318
|
+
ctx: Context[Any, Any, Any],
|
|
319
|
+
after: str,
|
|
320
|
+
type: list[str] | None = None,
|
|
321
|
+
owned_by: list[str] | None = None,
|
|
322
|
+
applies_to_part: list[str] | None = None,
|
|
323
|
+
limit: int | None = None,
|
|
324
|
+
) -> dict[str, Any]:
|
|
325
|
+
"""List DevRev work items (tickets, issues, and tasks) modified at or after a timestamp.
|
|
326
|
+
|
|
327
|
+
Pages through results server-side sorted by ``modified_date:desc`` and
|
|
328
|
+
returns all matches. Use ``limit`` as a hard cap on total items.
|
|
329
|
+
|
|
330
|
+
Args:
|
|
331
|
+
after: ISO-8601 timestamp (e.g., ``2024-01-15T00:00:00Z``). Items with
|
|
332
|
+
``modified_date >= after`` are returned.
|
|
333
|
+
type: Filter by work type(s): TICKET, ISSUE, TASK, OPPORTUNITY.
|
|
334
|
+
owned_by: Filter by owner user ID(s).
|
|
335
|
+
applies_to_part: Filter by part ID(s) the work applies to.
|
|
336
|
+
limit: Maximum total items to return across all pages.
|
|
337
|
+
"""
|
|
338
|
+
after_dt = _parse_iso_after(after, "after")
|
|
339
|
+
app = ctx.request_context.lifespan_context
|
|
340
|
+
try:
|
|
341
|
+
work_types = None
|
|
342
|
+
if type:
|
|
343
|
+
try:
|
|
344
|
+
work_types = [WorkType[t.upper()] for t in type]
|
|
345
|
+
except KeyError as e:
|
|
346
|
+
raise RuntimeError(
|
|
347
|
+
f"Invalid work type: {e.args[0]}. "
|
|
348
|
+
f"Valid types: {', '.join(wt.name for wt in WorkType)}"
|
|
349
|
+
) from e
|
|
350
|
+
works = await app.get_client().works.list_modified_since(
|
|
351
|
+
after_dt,
|
|
352
|
+
type=work_types,
|
|
353
|
+
owned_by=owned_by,
|
|
354
|
+
applies_to_part=applies_to_part,
|
|
355
|
+
limit=limit,
|
|
356
|
+
)
|
|
357
|
+
items = serialize_models(list(works))
|
|
358
|
+
return paginated_response(items, next_cursor=None, total_label="works")
|
|
359
|
+
except DevRevError as e:
|
|
360
|
+
raise RuntimeError(format_devrev_error(e)) from e
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
@mcp.tool()
|
|
364
|
+
async def devrev_works_list_created_since(
|
|
365
|
+
ctx: Context[Any, Any, Any],
|
|
366
|
+
after: str,
|
|
367
|
+
type: list[str] | None = None,
|
|
368
|
+
owned_by: list[str] | None = None,
|
|
369
|
+
applies_to_part: list[str] | None = None,
|
|
370
|
+
limit: int | None = None,
|
|
371
|
+
) -> dict[str, Any]:
|
|
372
|
+
"""List DevRev work items (tickets, issues, and tasks) created at or after a timestamp.
|
|
373
|
+
|
|
374
|
+
Pages through results server-side sorted by ``created_date:desc`` and
|
|
375
|
+
returns all matches. Use ``limit`` as a hard cap on total items.
|
|
376
|
+
|
|
377
|
+
Args:
|
|
378
|
+
after: ISO-8601 timestamp (e.g., ``2024-01-15T00:00:00Z``). Items with
|
|
379
|
+
``created_date >= after`` are returned.
|
|
380
|
+
type: Filter by work type(s): TICKET, ISSUE, TASK, OPPORTUNITY.
|
|
381
|
+
owned_by: Filter by owner user ID(s).
|
|
382
|
+
applies_to_part: Filter by part ID(s) the work applies to.
|
|
383
|
+
limit: Maximum total items to return across all pages.
|
|
384
|
+
"""
|
|
385
|
+
after_dt = _parse_iso_after(after, "after")
|
|
386
|
+
app = ctx.request_context.lifespan_context
|
|
387
|
+
try:
|
|
388
|
+
work_types = None
|
|
389
|
+
if type:
|
|
390
|
+
try:
|
|
391
|
+
work_types = [WorkType[t.upper()] for t in type]
|
|
392
|
+
except KeyError as e:
|
|
393
|
+
raise RuntimeError(
|
|
394
|
+
f"Invalid work type: {e.args[0]}. "
|
|
395
|
+
f"Valid types: {', '.join(wt.name for wt in WorkType)}"
|
|
396
|
+
) from e
|
|
397
|
+
works = await app.get_client().works.list_created_since(
|
|
398
|
+
after_dt,
|
|
399
|
+
type=work_types,
|
|
400
|
+
owned_by=owned_by,
|
|
401
|
+
applies_to_part=applies_to_part,
|
|
402
|
+
limit=limit,
|
|
403
|
+
)
|
|
404
|
+
items = serialize_models(list(works))
|
|
405
|
+
return paginated_response(items, next_cursor=None, total_label="works")
|
|
406
|
+
except DevRevError as e:
|
|
407
|
+
raise RuntimeError(format_devrev_error(e)) from e
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devrev-Python-SDK
|
|
3
|
-
Version:
|
|
3
|
+
Version: 3.0.0
|
|
4
4
|
Summary: A modern, type-safe Python SDK for the DevRev API
|
|
5
5
|
Project-URL: Homepage, https://github.com/mgmonteleone/py-dev-rev
|
|
6
6
|
Project-URL: Documentation, https://github.com/mgmonteleone/py-dev-rev
|
|
@@ -10,7 +10,7 @@ devrev/models/artifacts.py,sha256=3iA58RyVlX0S0U5iID3Cnoc096Y70QfGqYfKEhxfE9g,43
|
|
|
10
10
|
devrev/models/base.py,sha256=qMxfptmsWimH-MMv84-qojy9Ip858ZYMe6GvTQkDAjo,4511
|
|
11
11
|
devrev/models/brands.py,sha256=6nYVGoy7s8iDYLotfXefMTPIMIJoI8978TXm4RtIswY,3021
|
|
12
12
|
devrev/models/code_changes.py,sha256=cJsv5aX6o859cmhbNwuQBLdhIi5XrN26lBvVF1IfiYs,3420
|
|
13
|
-
devrev/models/conversations.py,sha256=
|
|
13
|
+
devrev/models/conversations.py,sha256=SwyMFbqziaAo75daxdEl6DS-pEl9a-GFXE4Z-AKACMg,4324
|
|
14
14
|
devrev/models/dev_users.py,sha256=0qQtd7pl2v2SF0pFjlzDWaVOUzI_U_uvNu4kXuXFfBs,9226
|
|
15
15
|
devrev/models/engagements.py,sha256=8mz1KC_lCQIWHcSqX5q_ViQoV4Q9AtW3mcEEQECoAxc,5201
|
|
16
16
|
devrev/models/groups.py,sha256=oB3eCuX14po5E8WhqkuuC-GlC1WXEPVfpqB5tn4NZ-o,4486
|
|
@@ -34,15 +34,15 @@ devrev/models/track_events.py,sha256=bb9belz1W3XvrUEgp9mGdOKELhRcWL-59wS9cIa1geY
|
|
|
34
34
|
devrev/models/uoms.py,sha256=bxjk2YhJ3qrX6C52xuokgLCHD3K4-8DRDeu2QwjnK4E,5700
|
|
35
35
|
devrev/models/webhooks.py,sha256=DzrLJiW1GYWvfaB-aVPcYCf4XkEGHz3dkyzbBKKdhFI,3655
|
|
36
36
|
devrev/models/widgets.py,sha256=7WWN17_ySqnu1pjYSIS5B8J5dEaqkoB8X8GWzvI0ZYc,4577
|
|
37
|
-
devrev/models/works.py,sha256=
|
|
37
|
+
devrev/models/works.py,sha256=sHZ_eJ6eCadWxPkM6HGcmbkiGRDYT2RnH3QZRO4T3tY,11198
|
|
38
38
|
devrev/services/__init__.py,sha256=LWy_0xGNH692d6Wc4S4E9v6AoxjgfExFMzFmbrL2NTY,3975
|
|
39
39
|
devrev/services/accounts.py,sha256=X7FgcODex0XKLiV_VvXKDl2Jm8XsNpn9qp40oRjZqME,9704
|
|
40
40
|
devrev/services/articles.py,sha256=xOpJOG9f29a5W3IFROplL1a9eJdGucbUFkK-MAIIYlc,41637
|
|
41
41
|
devrev/services/artifacts.py,sha256=SJzIi5M4np0ENoOTTGEAcoqoRFMVd8pe-BCo9vvhYzk,14124
|
|
42
|
-
devrev/services/base.py,sha256=
|
|
42
|
+
devrev/services/base.py,sha256=pzUOkOA0kc7yxQQS8AP65L-inpW1WxpsYwtZwA_LXk4,7174
|
|
43
43
|
devrev/services/brands.py,sha256=W6FB9XXTtEzGOfm9IBI6dqY8WXrMeH6urwjiZKrDVgI,5679
|
|
44
44
|
devrev/services/code_changes.py,sha256=ahDniXCz54GGmTFmNh88heUg0mHQcIAgaqwlqm1CP6E,3199
|
|
45
|
-
devrev/services/conversations.py,sha256=
|
|
45
|
+
devrev/services/conversations.py,sha256=Kx1pOyBc1Wkk32PmPhjXlM2KnLlLuN5Ydzrm9y5iJhM,13635
|
|
46
46
|
devrev/services/dev_users.py,sha256=YDJUNk6Crz2TN_uwkWQIBmztIYrLd8EX3toNLdb_HEk,11870
|
|
47
47
|
devrev/services/engagements.py,sha256=nT6AElHbFO40utp6R_QCGwTGunXjVci73I8YAtkqu8A,9094
|
|
48
48
|
devrev/services/groups.py,sha256=0VpOKTwHiRjrKXrO1kdHo8V_z2I02GphYQhyDFnImxg,4960
|
|
@@ -62,7 +62,7 @@ devrev/services/timeline_entries.py,sha256=6nbVPcWlE3-ohLhvkM421eqYS5ztKtHmGsPek
|
|
|
62
62
|
devrev/services/track_events.py,sha256=lI4wXkWu3uUuXtuRg1MGNkTZ7B0Lc1PjM8Kw-6sUnWc,1439
|
|
63
63
|
devrev/services/uoms.py,sha256=AA3ymoHj24FIbsZpYC4tg2elSdQ3iINTVOz7MraZcj8,8163
|
|
64
64
|
devrev/services/webhooks.py,sha256=-TSkcaya1y48WB24_vHd-bqO5xSqxRsCLiilncNzQZU,3917
|
|
65
|
-
devrev/services/works.py,sha256=
|
|
65
|
+
devrev/services/works.py,sha256=EUlDaUOttiRhvFLa0tkZXtgF3PpLLZlyUqjf3Hp4zRo,21239
|
|
66
66
|
devrev/utils/__init__.py,sha256=NOrbpkjDVLH8n9xf-xpZJiIIa_GVI_6vqTm3E8L3Udw,857
|
|
67
67
|
devrev/utils/content_converter.py,sha256=emRBLiVoOfDGpPDzrMRnqQr4-QkqN13OdWlYOyU_LCg,28141
|
|
68
68
|
devrev/utils/deprecation.py,sha256=7qB2Dx531oP7mNi7q2txOYsOKC9YwdHqlKPMFHOW9Ws,1275
|
|
@@ -95,7 +95,7 @@ devrev_mcp/resources/user.py,sha256=0Paq2w_nbj_dCQ8R0S81zlgjUhDAUzvn1_NmshadqM8,
|
|
|
95
95
|
devrev_mcp/tools/__init__.py,sha256=wiou4HHy6HeOQY0El3KYqy_S7c2IC4hjsYHjMm7aH-w,54
|
|
96
96
|
devrev_mcp/tools/accounts.py,sha256=CKD5bKxCNpm_60lakcrG9pBwNTrWOncH6MVQF4rT5wQ,6173
|
|
97
97
|
devrev_mcp/tools/articles.py,sha256=tna2ZLcefAaYbvICbRicM-0gk1HEx5HJSLS5Sz9t0iI,14063
|
|
98
|
-
devrev_mcp/tools/conversations.py,sha256=
|
|
98
|
+
devrev_mcp/tools/conversations.py,sha256=aEH8MOAQbdOEyIWbqGLYK2JjXcTaxAJqB5prBzmXvpY,8836
|
|
99
99
|
devrev_mcp/tools/engagements.py,sha256=21zsMLlgY1FwKzSIxPf6XVdMY77p8NXrWZ7l8PRKI-0,8928
|
|
100
100
|
devrev_mcp/tools/groups.py,sha256=E29eptwl_1rbmMPPElcSblWs71t1XdvSZIl1UR8TZr0,8456
|
|
101
101
|
devrev_mcp/tools/incidents.py,sha256=XO9FaZlLHCXULCFpMpCOftYNfvajH5PeXOkW2uUZ_Z4,7984
|
|
@@ -110,13 +110,13 @@ devrev_mcp/tools/slas.py,sha256=q7eYC7nMxCGoHMDp1QbtCgSWiG4oHk78sydPtYwsh10,4902
|
|
|
110
110
|
devrev_mcp/tools/tags.py,sha256=zj_my9CkPQu8_TTOqszfLULPNgxm83kHux-rWFxHvXQ,4711
|
|
111
111
|
devrev_mcp/tools/timeline.py,sha256=p7P699Oq205jrzCVyYS6v4bXHEQizircHVyS5JE-GJ4,5326
|
|
112
112
|
devrev_mcp/tools/users.py,sha256=7CtrxjgxniEb5-DaPh0rwL680a2fDFLJSIU5huuTjYE,4896
|
|
113
|
-
devrev_mcp/tools/works.py,sha256=
|
|
113
|
+
devrev_mcp/tools/works.py,sha256=IFaIXYR4j_2pZ39ykZgdHdo5nCk8OfgWU5bxBdHwp5E,15241
|
|
114
114
|
devrev_mcp/utils/__init__.py,sha256=2_5b1KC5kjoUqFY1ZSdB2Tefd2ekjbZ-eHyFWBKI-0A,49
|
|
115
115
|
devrev_mcp/utils/don_id.py,sha256=PAVzOSgaiqJQSdo1fwohFHq2x7PDNUZoBTN4LFGYM48,6294
|
|
116
116
|
devrev_mcp/utils/errors.py,sha256=5mRAo76rJvvEVi6b1ZokPxDtX5JKkptaqmiYDLCkwBE,2110
|
|
117
117
|
devrev_mcp/utils/formatting.py,sha256=6JssG5x1BxjdgSiQ8Ou3H-9Wo3wgWTWmejsrGez4wKc,2431
|
|
118
118
|
devrev_mcp/utils/pagination.py,sha256=EOUgL-ZdSToM1Q-ydXmjhibsef5K1u1g3CaS9K8I2fY,1286
|
|
119
|
-
devrev_python_sdk-
|
|
120
|
-
devrev_python_sdk-
|
|
121
|
-
devrev_python_sdk-
|
|
122
|
-
devrev_python_sdk-
|
|
119
|
+
devrev_python_sdk-3.0.0.dist-info/METADATA,sha256=X6qGjwru56IRRe8VAMJqh8AQTWKhjn0r4S_-GjGkbc4,40906
|
|
120
|
+
devrev_python_sdk-3.0.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
121
|
+
devrev_python_sdk-3.0.0.dist-info/entry_points.txt,sha256=XiV4J_yy0yzVZVxg7T66YERVIlqdPNp3O-NHTHkllqQ,63
|
|
122
|
+
devrev_python_sdk-3.0.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|