devrev-Python-SDK 2.14.1__py3-none-any.whl → 3.0.1__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/_pagination.py +54 -0
- devrev/services/base.py +6 -2
- devrev/services/conversations.py +130 -0
- devrev/services/works.py +271 -10
- devrev_mcp/tools/conversations.py +77 -0
- devrev_mcp/tools/works.py +123 -7
- {devrev_python_sdk-2.14.1.dist-info → devrev_python_sdk-3.0.1.dist-info}/METADATA +1 -1
- {devrev_python_sdk-2.14.1.dist-info → devrev_python_sdk-3.0.1.dist-info}/RECORD +12 -11
- {devrev_python_sdk-2.14.1.dist-info → devrev_python_sdk-3.0.1.dist-info}/WHEEL +0 -0
- {devrev_python_sdk-2.14.1.dist-info → devrev_python_sdk-3.0.1.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):
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Shared pagination helpers for cursor-streaming list helpers.
|
|
2
|
+
|
|
3
|
+
The DevRev ``*.list`` endpoints cap per-page responses at 100 items and the
|
|
4
|
+
corresponding ``*ListRequest.limit`` fields are pydantic-constrained to
|
|
5
|
+
``le=100``. Any service that drives cursor pagination from a user-supplied
|
|
6
|
+
``overall_limit`` / ``page_size`` pair must therefore clamp the value it puts
|
|
7
|
+
on the request body before construction, or the request will fail validation
|
|
8
|
+
locally before it is ever sent.
|
|
9
|
+
|
|
10
|
+
:func:`resolve_page_limit` centralises that clamping so every ``list_*_since``
|
|
11
|
+
helper resolves the per-page ``limit`` identically.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Final
|
|
17
|
+
|
|
18
|
+
_MAX_PAGE: Final[int] = 100
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def resolve_page_limit(
|
|
22
|
+
overall_limit: int | None,
|
|
23
|
+
collected: int,
|
|
24
|
+
page_size: int | None,
|
|
25
|
+
) -> int | None:
|
|
26
|
+
"""Compute the ``limit`` to send for the next page request.
|
|
27
|
+
|
|
28
|
+
The returned value is always clamped to :data:`_MAX_PAGE` so callers using
|
|
29
|
+
an ``overall_limit`` greater than the server maximum (e.g. ``limit=200,
|
|
30
|
+
page_size=None``) still paginate correctly. When both ``overall_limit`` and
|
|
31
|
+
``page_size`` are supplied the tightest of ``{page_size, remaining,
|
|
32
|
+
_MAX_PAGE}`` wins.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
overall_limit: Caller-supplied hard cap on total items to return across
|
|
36
|
+
all pages, or ``None`` to stream until the server runs out of
|
|
37
|
+
results.
|
|
38
|
+
collected: Number of items already accumulated across prior pages.
|
|
39
|
+
page_size: Caller-supplied per-page size, or ``None`` to defer to the
|
|
40
|
+
server default (still clamped to :data:`_MAX_PAGE`).
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
The ``limit`` to put on the next ``*ListRequest``, or ``None`` to omit
|
|
44
|
+
it entirely (only when both ``overall_limit`` and ``page_size`` are
|
|
45
|
+
``None``).
|
|
46
|
+
"""
|
|
47
|
+
if overall_limit is None:
|
|
48
|
+
if page_size is None:
|
|
49
|
+
return None
|
|
50
|
+
return min(page_size, _MAX_PAGE)
|
|
51
|
+
remaining = overall_limit - collected
|
|
52
|
+
if page_size is None:
|
|
53
|
+
return min(remaining, _MAX_PAGE)
|
|
54
|
+
return min(page_size, remaining, _MAX_PAGE)
|
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,
|
|
@@ -21,9 +23,45 @@ from devrev.models.conversations import (
|
|
|
21
23
|
ConversationsUpdateRequest,
|
|
22
24
|
ConversationsUpdateResponse,
|
|
23
25
|
)
|
|
26
|
+
from devrev.services._pagination import resolve_page_limit
|
|
24
27
|
from devrev.services.base import AsyncBaseService, BaseService
|
|
25
28
|
|
|
26
29
|
|
|
30
|
+
def _normalize_sort_by(sort_by: Sequence[str] | None) -> list[str] | None:
|
|
31
|
+
"""Normalize sort_by entries to the ``field:direction`` format.
|
|
32
|
+
|
|
33
|
+
Accepts entries already in ``field:direction`` form (e.g.
|
|
34
|
+
``"modified_date:desc"``), the legacy ``"-field"`` shorthand for
|
|
35
|
+
descending order, and bare field names (e.g. ``"modified_date"``) which
|
|
36
|
+
default to ascending order.
|
|
37
|
+
"""
|
|
38
|
+
if sort_by is None:
|
|
39
|
+
return None
|
|
40
|
+
normalized: list[str] = []
|
|
41
|
+
for entry in sort_by:
|
|
42
|
+
if ":" in entry:
|
|
43
|
+
normalized.append(entry)
|
|
44
|
+
elif entry.startswith("-"):
|
|
45
|
+
normalized.append(f"{entry[1:]}:desc")
|
|
46
|
+
else:
|
|
47
|
+
normalized.append(f"{entry}:asc")
|
|
48
|
+
return normalized
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _is_before_cutoff(modified_date: datetime | None, cutoff: datetime) -> bool:
|
|
52
|
+
"""Return True if ``modified_date`` is strictly older than ``cutoff``.
|
|
53
|
+
|
|
54
|
+
Returns False when the timestamp is unknown or when the two datetimes have
|
|
55
|
+
incompatible tz-awareness; the server-side filter remains authoritative.
|
|
56
|
+
"""
|
|
57
|
+
if modified_date is None:
|
|
58
|
+
return False
|
|
59
|
+
try:
|
|
60
|
+
return modified_date < cutoff
|
|
61
|
+
except TypeError:
|
|
62
|
+
return False
|
|
63
|
+
|
|
64
|
+
|
|
27
65
|
class ConversationsService(BaseService):
|
|
28
66
|
"""Service for managing DevRev Conversations."""
|
|
29
67
|
|
|
@@ -41,9 +79,55 @@ class ConversationsService(BaseService):
|
|
|
41
79
|
"""List conversations."""
|
|
42
80
|
if request is None:
|
|
43
81
|
request = ConversationsListRequest()
|
|
82
|
+
if request.sort_by is not None:
|
|
83
|
+
request.sort_by = _normalize_sort_by(request.sort_by)
|
|
44
84
|
response = self._post("/conversations.list", request, ConversationsListResponse)
|
|
45
85
|
return response.conversations
|
|
46
86
|
|
|
87
|
+
def list_modified_since(
|
|
88
|
+
self,
|
|
89
|
+
after: datetime,
|
|
90
|
+
*,
|
|
91
|
+
limit: int | None = None,
|
|
92
|
+
page_size: int | None = None,
|
|
93
|
+
) -> Sequence[Conversation]:
|
|
94
|
+
"""List conversations modified after a given datetime, newest first.
|
|
95
|
+
|
|
96
|
+
Streams pages via cursor until the server returns no further cursor,
|
|
97
|
+
``limit`` is reached, or a conversation older than ``after`` is seen.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
after: Only include conversations modified after this datetime.
|
|
101
|
+
limit: Maximum number of conversations to return overall.
|
|
102
|
+
page_size: Number of results per API request; ``None`` defers to server.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
List of Conversation objects modified after ``after``, newest first.
|
|
106
|
+
"""
|
|
107
|
+
results: list[Conversation] = []
|
|
108
|
+
cursor: str | None = None
|
|
109
|
+
while True:
|
|
110
|
+
if limit is not None and len(results) >= limit:
|
|
111
|
+
break
|
|
112
|
+
request_limit = resolve_page_limit(limit, len(results), page_size)
|
|
113
|
+
request = ConversationsListRequest(
|
|
114
|
+
cursor=cursor,
|
|
115
|
+
limit=request_limit,
|
|
116
|
+
modified_date=DateFilter(after=after),
|
|
117
|
+
sort_by=_normalize_sort_by(["modified_date:desc"]),
|
|
118
|
+
)
|
|
119
|
+
response = self._post("/conversations.list", request, ConversationsListResponse)
|
|
120
|
+
for conversation in response.conversations:
|
|
121
|
+
if _is_before_cutoff(conversation.modified_date, after):
|
|
122
|
+
return results
|
|
123
|
+
results.append(conversation)
|
|
124
|
+
if limit is not None and len(results) >= limit:
|
|
125
|
+
return results
|
|
126
|
+
if not response.next_cursor:
|
|
127
|
+
break
|
|
128
|
+
cursor = response.next_cursor
|
|
129
|
+
return results
|
|
130
|
+
|
|
47
131
|
def update(self, request: ConversationsUpdateRequest) -> Conversation:
|
|
48
132
|
"""Update a conversation."""
|
|
49
133
|
response = self._post("/conversations.update", request, ConversationsUpdateResponse)
|
|
@@ -127,9 +211,55 @@ class AsyncConversationsService(AsyncBaseService):
|
|
|
127
211
|
"""List conversations."""
|
|
128
212
|
if request is None:
|
|
129
213
|
request = ConversationsListRequest()
|
|
214
|
+
if request.sort_by is not None:
|
|
215
|
+
request.sort_by = _normalize_sort_by(request.sort_by)
|
|
130
216
|
response = await self._post("/conversations.list", request, ConversationsListResponse)
|
|
131
217
|
return response.conversations
|
|
132
218
|
|
|
219
|
+
async def list_modified_since(
|
|
220
|
+
self,
|
|
221
|
+
after: datetime,
|
|
222
|
+
*,
|
|
223
|
+
limit: int | None = None,
|
|
224
|
+
page_size: int | None = None,
|
|
225
|
+
) -> Sequence[Conversation]:
|
|
226
|
+
"""List conversations modified after a given datetime, newest first.
|
|
227
|
+
|
|
228
|
+
Streams pages via cursor until the server returns no further cursor,
|
|
229
|
+
``limit`` is reached, or a conversation older than ``after`` is seen.
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
after: Only include conversations modified after this datetime.
|
|
233
|
+
limit: Maximum number of conversations to return overall.
|
|
234
|
+
page_size: Number of results per API request; ``None`` defers to server.
|
|
235
|
+
|
|
236
|
+
Returns:
|
|
237
|
+
List of Conversation objects modified after ``after``, newest first.
|
|
238
|
+
"""
|
|
239
|
+
results: list[Conversation] = []
|
|
240
|
+
cursor: str | None = None
|
|
241
|
+
while True:
|
|
242
|
+
if limit is not None and len(results) >= limit:
|
|
243
|
+
break
|
|
244
|
+
request_limit = resolve_page_limit(limit, len(results), page_size)
|
|
245
|
+
request = ConversationsListRequest(
|
|
246
|
+
cursor=cursor,
|
|
247
|
+
limit=request_limit,
|
|
248
|
+
modified_date=DateFilter(after=after),
|
|
249
|
+
sort_by=_normalize_sort_by(["modified_date:desc"]),
|
|
250
|
+
)
|
|
251
|
+
response = await self._post("/conversations.list", request, ConversationsListResponse)
|
|
252
|
+
for conversation in response.conversations:
|
|
253
|
+
if _is_before_cutoff(conversation.modified_date, after):
|
|
254
|
+
return results
|
|
255
|
+
results.append(conversation)
|
|
256
|
+
if limit is not None and len(results) >= limit:
|
|
257
|
+
return results
|
|
258
|
+
if not response.next_cursor:
|
|
259
|
+
break
|
|
260
|
+
cursor = response.next_cursor
|
|
261
|
+
return results
|
|
262
|
+
|
|
133
263
|
async def update(self, request: ConversationsUpdateRequest) -> Conversation:
|
|
134
264
|
"""Update a conversation."""
|
|
135
265
|
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,
|
|
@@ -31,12 +31,56 @@ from devrev.models.works import (
|
|
|
31
31
|
WorksUpdateResponse,
|
|
32
32
|
WorkType,
|
|
33
33
|
)
|
|
34
|
+
from devrev.services._pagination import resolve_page_limit
|
|
34
35
|
from devrev.services.base import AsyncBaseService, BaseService
|
|
35
36
|
|
|
36
37
|
if TYPE_CHECKING:
|
|
37
38
|
from devrev.utils.http import AsyncHTTPClient, HTTPClient
|
|
38
39
|
|
|
39
40
|
|
|
41
|
+
# Module-level type aliases. Declared here so class-scoped annotations can
|
|
42
|
+
# refer to ``list[...]`` without colliding with the ``list`` method defined on
|
|
43
|
+
# :class:`WorksService` / :class:`AsyncWorksService`.
|
|
44
|
+
_WorkList = list[Work]
|
|
45
|
+
_StrList = list[str]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _is_before_cutoff(timestamp: datetime | None, cutoff: datetime) -> bool:
|
|
49
|
+
"""Return True if ``timestamp`` is strictly older than ``cutoff``.
|
|
50
|
+
|
|
51
|
+
Returns False when the timestamp is unknown or when the two datetimes have
|
|
52
|
+
incompatible tz-awareness; the server-side filter remains authoritative.
|
|
53
|
+
"""
|
|
54
|
+
if timestamp is None:
|
|
55
|
+
return False
|
|
56
|
+
try:
|
|
57
|
+
return timestamp < cutoff
|
|
58
|
+
except TypeError:
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _normalize_sort_by(sort_by: Sequence[str] | None) -> _StrList | None:
|
|
63
|
+
"""Normalize sort_by entries to the server-expected ``field:direction`` form.
|
|
64
|
+
|
|
65
|
+
Accepts both the legacy ``"-field"`` (descending) / ``"field"`` (ascending)
|
|
66
|
+
shorthand and the explicit ``"field:asc"`` / ``"field:desc"`` form. Returns
|
|
67
|
+
a new list with every entry in the ``"field:direction"`` form. Returns
|
|
68
|
+
``None`` when the input is ``None`` so callers can pass the result straight
|
|
69
|
+
through to request models with no-op semantics.
|
|
70
|
+
"""
|
|
71
|
+
if sort_by is None:
|
|
72
|
+
return None
|
|
73
|
+
normalized: list[str] = []
|
|
74
|
+
for entry in sort_by:
|
|
75
|
+
if ":" in entry:
|
|
76
|
+
normalized.append(entry)
|
|
77
|
+
elif entry.startswith("-"):
|
|
78
|
+
normalized.append(f"{entry[1:]}:desc")
|
|
79
|
+
else:
|
|
80
|
+
normalized.append(f"{entry}:asc")
|
|
81
|
+
return normalized
|
|
82
|
+
|
|
83
|
+
|
|
40
84
|
class WorksService(BaseService):
|
|
41
85
|
"""Synchronous service for managing DevRev work items.
|
|
42
86
|
|
|
@@ -109,10 +153,10 @@ class WorksService(BaseService):
|
|
|
109
153
|
type: Sequence[WorkType] | None = None,
|
|
110
154
|
applies_to_part: Sequence[str] | None = None,
|
|
111
155
|
created_by: Sequence[str] | None = None,
|
|
112
|
-
created_date: DateFilter | None = None,
|
|
113
156
|
cursor: str | None = None,
|
|
114
157
|
limit: int | None = None,
|
|
115
158
|
owned_by: Sequence[str] | None = None,
|
|
159
|
+
sort_by: Sequence[str] | None = None,
|
|
116
160
|
stage_name: Sequence[str] | None = None,
|
|
117
161
|
) -> WorksListResponse:
|
|
118
162
|
"""List work items.
|
|
@@ -121,10 +165,12 @@ class WorksService(BaseService):
|
|
|
121
165
|
type: Filter by work types
|
|
122
166
|
applies_to_part: Filter by part IDs
|
|
123
167
|
created_by: Filter by creator user IDs
|
|
124
|
-
created_date: Filter by creation date
|
|
125
168
|
cursor: Pagination cursor
|
|
126
169
|
limit: Maximum number of results
|
|
127
170
|
owned_by: Filter by owner user IDs
|
|
171
|
+
sort_by: Sort order. Accepts either the server form
|
|
172
|
+
``"field:asc"`` / ``"field:desc"`` or the legacy
|
|
173
|
+
``"-field"`` shorthand; the client normalizes before sending.
|
|
128
174
|
stage_name: Filter by stage names
|
|
129
175
|
|
|
130
176
|
Returns:
|
|
@@ -134,10 +180,10 @@ class WorksService(BaseService):
|
|
|
134
180
|
type=type,
|
|
135
181
|
applies_to_part=applies_to_part,
|
|
136
182
|
created_by=created_by,
|
|
137
|
-
created_date=created_date,
|
|
138
183
|
cursor=cursor,
|
|
139
184
|
limit=limit,
|
|
140
185
|
owned_by=owned_by,
|
|
186
|
+
sort_by=_normalize_sort_by(sort_by),
|
|
141
187
|
stage_name=stage_name,
|
|
142
188
|
)
|
|
143
189
|
return self._post("/works.list", request, WorksListResponse)
|
|
@@ -198,8 +244,8 @@ class WorksService(BaseService):
|
|
|
198
244
|
type: Sequence[WorkType] | None = None,
|
|
199
245
|
applies_to_part: Sequence[str] | None = None,
|
|
200
246
|
created_by: Sequence[str] | None = None,
|
|
201
|
-
created_date: DateFilter | None = None,
|
|
202
247
|
first: int | None = None,
|
|
248
|
+
sort_by: Sequence[str] | None = None,
|
|
203
249
|
) -> Sequence[Work]:
|
|
204
250
|
"""Export work items.
|
|
205
251
|
|
|
@@ -207,8 +253,10 @@ class WorksService(BaseService):
|
|
|
207
253
|
type: Filter by work types
|
|
208
254
|
applies_to_part: Filter by part IDs
|
|
209
255
|
created_by: Filter by creator user IDs
|
|
210
|
-
created_date: Filter by creation date
|
|
211
256
|
first: Maximum number of results
|
|
257
|
+
sort_by: Sort order. Accepts either the server form
|
|
258
|
+
``"field:asc"`` / ``"field:desc"`` or the legacy
|
|
259
|
+
``"-field"`` shorthand; the client normalizes before sending.
|
|
212
260
|
|
|
213
261
|
Returns:
|
|
214
262
|
List of exported work items
|
|
@@ -217,8 +265,8 @@ class WorksService(BaseService):
|
|
|
217
265
|
type=type,
|
|
218
266
|
applies_to_part=applies_to_part,
|
|
219
267
|
created_by=created_by,
|
|
220
|
-
created_date=created_date,
|
|
221
268
|
first=first,
|
|
269
|
+
sort_by=_normalize_sort_by(sort_by),
|
|
222
270
|
)
|
|
223
271
|
response = self._post("/works.export", request, WorksExportResponse)
|
|
224
272
|
return response.works
|
|
@@ -251,6 +299,104 @@ class WorksService(BaseService):
|
|
|
251
299
|
response = self._post("/works.count", request, WorksCountResponse)
|
|
252
300
|
return response.count
|
|
253
301
|
|
|
302
|
+
def _list_since(
|
|
303
|
+
self,
|
|
304
|
+
after: datetime,
|
|
305
|
+
timestamp_field: str,
|
|
306
|
+
*,
|
|
307
|
+
type: Sequence[WorkType] | None,
|
|
308
|
+
owned_by: Sequence[str] | None,
|
|
309
|
+
applies_to_part: Sequence[str] | None,
|
|
310
|
+
limit: int | None,
|
|
311
|
+
page_size: int | None,
|
|
312
|
+
) -> _WorkList:
|
|
313
|
+
"""Shared cursor-paginated fetcher for ``list_*_since`` helpers.
|
|
314
|
+
|
|
315
|
+
Streams pages sorted ``{timestamp_field}:desc`` and early-exits as soon
|
|
316
|
+
as a record's timestamp is strictly older than ``after``. Respects
|
|
317
|
+
``limit`` as a hard cap on returned items.
|
|
318
|
+
"""
|
|
319
|
+
sort_by = [f"{timestamp_field}:desc"]
|
|
320
|
+
collected: _WorkList = []
|
|
321
|
+
cursor: str | None = None
|
|
322
|
+
while True:
|
|
323
|
+
if limit is not None and len(collected) >= limit:
|
|
324
|
+
break
|
|
325
|
+
page = self.list(
|
|
326
|
+
type=type,
|
|
327
|
+
owned_by=owned_by,
|
|
328
|
+
applies_to_part=applies_to_part,
|
|
329
|
+
cursor=cursor,
|
|
330
|
+
limit=resolve_page_limit(limit, len(collected), page_size),
|
|
331
|
+
sort_by=sort_by,
|
|
332
|
+
)
|
|
333
|
+
stop = False
|
|
334
|
+
for work in page.works:
|
|
335
|
+
timestamp = getattr(work, timestamp_field, None)
|
|
336
|
+
if _is_before_cutoff(timestamp, after):
|
|
337
|
+
stop = True
|
|
338
|
+
break
|
|
339
|
+
collected.append(work)
|
|
340
|
+
if limit is not None and len(collected) >= limit:
|
|
341
|
+
stop = True
|
|
342
|
+
break
|
|
343
|
+
if stop or not page.next_cursor:
|
|
344
|
+
break
|
|
345
|
+
cursor = page.next_cursor
|
|
346
|
+
return collected
|
|
347
|
+
|
|
348
|
+
def list_modified_since(
|
|
349
|
+
self,
|
|
350
|
+
after: datetime,
|
|
351
|
+
*,
|
|
352
|
+
type: Sequence[WorkType] | None = None,
|
|
353
|
+
owned_by: Sequence[str] | None = None,
|
|
354
|
+
applies_to_part: Sequence[str] | None = None,
|
|
355
|
+
limit: int | None = None,
|
|
356
|
+
page_size: int | None = None,
|
|
357
|
+
) -> _WorkList:
|
|
358
|
+
"""Return work items modified at or after ``after``.
|
|
359
|
+
|
|
360
|
+
Pages through ``works.list`` sorted by ``modified_date:desc`` and stops
|
|
361
|
+
as soon as it sees a record older than ``after``. Uses ``limit`` as a
|
|
362
|
+
hard cap when provided.
|
|
363
|
+
"""
|
|
364
|
+
return self._list_since(
|
|
365
|
+
after,
|
|
366
|
+
"modified_date",
|
|
367
|
+
type=type,
|
|
368
|
+
owned_by=owned_by,
|
|
369
|
+
applies_to_part=applies_to_part,
|
|
370
|
+
limit=limit,
|
|
371
|
+
page_size=page_size,
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
def list_created_since(
|
|
375
|
+
self,
|
|
376
|
+
after: datetime,
|
|
377
|
+
*,
|
|
378
|
+
type: Sequence[WorkType] | None = None,
|
|
379
|
+
owned_by: Sequence[str] | None = None,
|
|
380
|
+
applies_to_part: Sequence[str] | None = None,
|
|
381
|
+
limit: int | None = None,
|
|
382
|
+
page_size: int | None = None,
|
|
383
|
+
) -> _WorkList:
|
|
384
|
+
"""Return work items created at or after ``after``.
|
|
385
|
+
|
|
386
|
+
Pages through ``works.list`` sorted by ``created_date:desc`` and stops
|
|
387
|
+
as soon as it sees a record older than ``after``. Uses ``limit`` as a
|
|
388
|
+
hard cap when provided.
|
|
389
|
+
"""
|
|
390
|
+
return self._list_since(
|
|
391
|
+
after,
|
|
392
|
+
"created_date",
|
|
393
|
+
type=type,
|
|
394
|
+
owned_by=owned_by,
|
|
395
|
+
applies_to_part=applies_to_part,
|
|
396
|
+
limit=limit,
|
|
397
|
+
page_size=page_size,
|
|
398
|
+
)
|
|
399
|
+
|
|
254
400
|
|
|
255
401
|
class AsyncWorksService(AsyncBaseService):
|
|
256
402
|
"""Asynchronous service for managing DevRev work items."""
|
|
@@ -301,14 +447,21 @@ class AsyncWorksService(AsyncBaseService):
|
|
|
301
447
|
cursor: str | None = None,
|
|
302
448
|
limit: int | None = None,
|
|
303
449
|
owned_by: Sequence[str] | None = None,
|
|
450
|
+
sort_by: Sequence[str] | None = None,
|
|
304
451
|
) -> WorksListResponse:
|
|
305
|
-
"""List work items.
|
|
452
|
+
"""List work items.
|
|
453
|
+
|
|
454
|
+
``sort_by`` accepts either the server form ``"field:asc"`` /
|
|
455
|
+
``"field:desc"`` or the legacy ``"-field"`` shorthand; the client
|
|
456
|
+
normalizes before sending.
|
|
457
|
+
"""
|
|
306
458
|
request = WorksListRequest(
|
|
307
459
|
type=type,
|
|
308
460
|
applies_to_part=applies_to_part,
|
|
309
461
|
cursor=cursor,
|
|
310
462
|
limit=limit,
|
|
311
463
|
owned_by=owned_by,
|
|
464
|
+
sort_by=_normalize_sort_by(sort_by),
|
|
312
465
|
)
|
|
313
466
|
return await self._post("/works.list", request, WorksListResponse)
|
|
314
467
|
|
|
@@ -345,9 +498,19 @@ class AsyncWorksService(AsyncBaseService):
|
|
|
345
498
|
*,
|
|
346
499
|
type: Sequence[WorkType] | None = None,
|
|
347
500
|
first: int | None = None,
|
|
501
|
+
sort_by: Sequence[str] | None = None,
|
|
348
502
|
) -> Sequence[Work]:
|
|
349
|
-
"""Export work items.
|
|
350
|
-
|
|
503
|
+
"""Export work items.
|
|
504
|
+
|
|
505
|
+
``sort_by`` accepts either the server form ``"field:asc"`` /
|
|
506
|
+
``"field:desc"`` or the legacy ``"-field"`` shorthand; the client
|
|
507
|
+
normalizes before sending.
|
|
508
|
+
"""
|
|
509
|
+
request = WorksExportRequest(
|
|
510
|
+
type=type,
|
|
511
|
+
first=first,
|
|
512
|
+
sort_by=_normalize_sort_by(sort_by),
|
|
513
|
+
)
|
|
351
514
|
response = await self._post("/works.export", request, WorksExportResponse)
|
|
352
515
|
return response.works
|
|
353
516
|
|
|
@@ -361,3 +524,101 @@ class AsyncWorksService(AsyncBaseService):
|
|
|
361
524
|
request = WorksCountRequest(type=type, owned_by=owned_by)
|
|
362
525
|
response = await self._post("/works.count", request, WorksCountResponse)
|
|
363
526
|
return response.count
|
|
527
|
+
|
|
528
|
+
async def _list_since(
|
|
529
|
+
self,
|
|
530
|
+
after: datetime,
|
|
531
|
+
timestamp_field: str,
|
|
532
|
+
*,
|
|
533
|
+
type: Sequence[WorkType] | None,
|
|
534
|
+
owned_by: Sequence[str] | None,
|
|
535
|
+
applies_to_part: Sequence[str] | None,
|
|
536
|
+
limit: int | None,
|
|
537
|
+
page_size: int | None,
|
|
538
|
+
) -> _WorkList:
|
|
539
|
+
"""Shared cursor-paginated fetcher for async ``list_*_since`` helpers.
|
|
540
|
+
|
|
541
|
+
Streams pages sorted ``{timestamp_field}:desc`` and early-exits as soon
|
|
542
|
+
as a record's timestamp is strictly older than ``after``. Respects
|
|
543
|
+
``limit`` as a hard cap on returned items.
|
|
544
|
+
"""
|
|
545
|
+
sort_by = [f"{timestamp_field}:desc"]
|
|
546
|
+
collected: _WorkList = []
|
|
547
|
+
cursor: str | None = None
|
|
548
|
+
while True:
|
|
549
|
+
if limit is not None and len(collected) >= limit:
|
|
550
|
+
break
|
|
551
|
+
page = await self.list(
|
|
552
|
+
type=type,
|
|
553
|
+
owned_by=owned_by,
|
|
554
|
+
applies_to_part=applies_to_part,
|
|
555
|
+
cursor=cursor,
|
|
556
|
+
limit=resolve_page_limit(limit, len(collected), page_size),
|
|
557
|
+
sort_by=sort_by,
|
|
558
|
+
)
|
|
559
|
+
stop = False
|
|
560
|
+
for work in page.works:
|
|
561
|
+
timestamp = getattr(work, timestamp_field, None)
|
|
562
|
+
if _is_before_cutoff(timestamp, after):
|
|
563
|
+
stop = True
|
|
564
|
+
break
|
|
565
|
+
collected.append(work)
|
|
566
|
+
if limit is not None and len(collected) >= limit:
|
|
567
|
+
stop = True
|
|
568
|
+
break
|
|
569
|
+
if stop or not page.next_cursor:
|
|
570
|
+
break
|
|
571
|
+
cursor = page.next_cursor
|
|
572
|
+
return collected
|
|
573
|
+
|
|
574
|
+
async def list_modified_since(
|
|
575
|
+
self,
|
|
576
|
+
after: datetime,
|
|
577
|
+
*,
|
|
578
|
+
type: Sequence[WorkType] | None = None,
|
|
579
|
+
owned_by: Sequence[str] | None = None,
|
|
580
|
+
applies_to_part: Sequence[str] | None = None,
|
|
581
|
+
limit: int | None = None,
|
|
582
|
+
page_size: int | None = None,
|
|
583
|
+
) -> _WorkList:
|
|
584
|
+
"""Return work items modified at or after ``after``.
|
|
585
|
+
|
|
586
|
+
Pages through ``works.list`` sorted by ``modified_date:desc`` and stops
|
|
587
|
+
as soon as it sees a record older than ``after``. Uses ``limit`` as a
|
|
588
|
+
hard cap when provided.
|
|
589
|
+
"""
|
|
590
|
+
return await self._list_since(
|
|
591
|
+
after,
|
|
592
|
+
"modified_date",
|
|
593
|
+
type=type,
|
|
594
|
+
owned_by=owned_by,
|
|
595
|
+
applies_to_part=applies_to_part,
|
|
596
|
+
limit=limit,
|
|
597
|
+
page_size=page_size,
|
|
598
|
+
)
|
|
599
|
+
|
|
600
|
+
async def list_created_since(
|
|
601
|
+
self,
|
|
602
|
+
after: datetime,
|
|
603
|
+
*,
|
|
604
|
+
type: Sequence[WorkType] | None = None,
|
|
605
|
+
owned_by: Sequence[str] | None = None,
|
|
606
|
+
applies_to_part: Sequence[str] | None = None,
|
|
607
|
+
limit: int | None = None,
|
|
608
|
+
page_size: int | None = None,
|
|
609
|
+
) -> _WorkList:
|
|
610
|
+
"""Return work items created at or after ``after``.
|
|
611
|
+
|
|
612
|
+
Pages through ``works.list`` sorted by ``created_date:desc`` and stops
|
|
613
|
+
as soon as it sees a record older than ``after``. Uses ``limit`` as a
|
|
614
|
+
hard cap when provided.
|
|
615
|
+
"""
|
|
616
|
+
return await self._list_since(
|
|
617
|
+
after,
|
|
618
|
+
"created_date",
|
|
619
|
+
type=type,
|
|
620
|
+
owned_by=owned_by,
|
|
621
|
+
applies_to_part=applies_to_part,
|
|
622
|
+
limit=limit,
|
|
623
|
+
page_size=page_size,
|
|
624
|
+
)
|
|
@@ -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.1
|
|
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,16 @@ 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
|
+
devrev/services/_pagination.py,sha256=J6AXu1P3iNRyC8_nH709Z8nOLhHeSctOLRmY_eYI6QA,2100
|
|
39
40
|
devrev/services/accounts.py,sha256=X7FgcODex0XKLiV_VvXKDl2Jm8XsNpn9qp40oRjZqME,9704
|
|
40
41
|
devrev/services/articles.py,sha256=xOpJOG9f29a5W3IFROplL1a9eJdGucbUFkK-MAIIYlc,41637
|
|
41
42
|
devrev/services/artifacts.py,sha256=SJzIi5M4np0ENoOTTGEAcoqoRFMVd8pe-BCo9vvhYzk,14124
|
|
42
|
-
devrev/services/base.py,sha256=
|
|
43
|
+
devrev/services/base.py,sha256=pzUOkOA0kc7yxQQS8AP65L-inpW1WxpsYwtZwA_LXk4,7174
|
|
43
44
|
devrev/services/brands.py,sha256=W6FB9XXTtEzGOfm9IBI6dqY8WXrMeH6urwjiZKrDVgI,5679
|
|
44
45
|
devrev/services/code_changes.py,sha256=ahDniXCz54GGmTFmNh88heUg0mHQcIAgaqwlqm1CP6E,3199
|
|
45
|
-
devrev/services/conversations.py,sha256=
|
|
46
|
+
devrev/services/conversations.py,sha256=Xw8hDZuwte57fH2Z3D19gd5nrm91OURcWkBrKAcbqAQ,12716
|
|
46
47
|
devrev/services/dev_users.py,sha256=YDJUNk6Crz2TN_uwkWQIBmztIYrLd8EX3toNLdb_HEk,11870
|
|
47
48
|
devrev/services/engagements.py,sha256=nT6AElHbFO40utp6R_QCGwTGunXjVci73I8YAtkqu8A,9094
|
|
48
49
|
devrev/services/groups.py,sha256=0VpOKTwHiRjrKXrO1kdHo8V_z2I02GphYQhyDFnImxg,4960
|
|
@@ -62,7 +63,7 @@ devrev/services/timeline_entries.py,sha256=6nbVPcWlE3-ohLhvkM421eqYS5ztKtHmGsPek
|
|
|
62
63
|
devrev/services/track_events.py,sha256=lI4wXkWu3uUuXtuRg1MGNkTZ7B0Lc1PjM8Kw-6sUnWc,1439
|
|
63
64
|
devrev/services/uoms.py,sha256=AA3ymoHj24FIbsZpYC4tg2elSdQ3iINTVOz7MraZcj8,8163
|
|
64
65
|
devrev/services/webhooks.py,sha256=-TSkcaya1y48WB24_vHd-bqO5xSqxRsCLiilncNzQZU,3917
|
|
65
|
-
devrev/services/works.py,sha256=
|
|
66
|
+
devrev/services/works.py,sha256=hAK7ZUqLph5Db15p4RDbeRFLZUALAF5tCFOQSYytj_g,20456
|
|
66
67
|
devrev/utils/__init__.py,sha256=NOrbpkjDVLH8n9xf-xpZJiIIa_GVI_6vqTm3E8L3Udw,857
|
|
67
68
|
devrev/utils/content_converter.py,sha256=emRBLiVoOfDGpPDzrMRnqQr4-QkqN13OdWlYOyU_LCg,28141
|
|
68
69
|
devrev/utils/deprecation.py,sha256=7qB2Dx531oP7mNi7q2txOYsOKC9YwdHqlKPMFHOW9Ws,1275
|
|
@@ -95,7 +96,7 @@ devrev_mcp/resources/user.py,sha256=0Paq2w_nbj_dCQ8R0S81zlgjUhDAUzvn1_NmshadqM8,
|
|
|
95
96
|
devrev_mcp/tools/__init__.py,sha256=wiou4HHy6HeOQY0El3KYqy_S7c2IC4hjsYHjMm7aH-w,54
|
|
96
97
|
devrev_mcp/tools/accounts.py,sha256=CKD5bKxCNpm_60lakcrG9pBwNTrWOncH6MVQF4rT5wQ,6173
|
|
97
98
|
devrev_mcp/tools/articles.py,sha256=tna2ZLcefAaYbvICbRicM-0gk1HEx5HJSLS5Sz9t0iI,14063
|
|
98
|
-
devrev_mcp/tools/conversations.py,sha256=
|
|
99
|
+
devrev_mcp/tools/conversations.py,sha256=aEH8MOAQbdOEyIWbqGLYK2JjXcTaxAJqB5prBzmXvpY,8836
|
|
99
100
|
devrev_mcp/tools/engagements.py,sha256=21zsMLlgY1FwKzSIxPf6XVdMY77p8NXrWZ7l8PRKI-0,8928
|
|
100
101
|
devrev_mcp/tools/groups.py,sha256=E29eptwl_1rbmMPPElcSblWs71t1XdvSZIl1UR8TZr0,8456
|
|
101
102
|
devrev_mcp/tools/incidents.py,sha256=XO9FaZlLHCXULCFpMpCOftYNfvajH5PeXOkW2uUZ_Z4,7984
|
|
@@ -110,13 +111,13 @@ devrev_mcp/tools/slas.py,sha256=q7eYC7nMxCGoHMDp1QbtCgSWiG4oHk78sydPtYwsh10,4902
|
|
|
110
111
|
devrev_mcp/tools/tags.py,sha256=zj_my9CkPQu8_TTOqszfLULPNgxm83kHux-rWFxHvXQ,4711
|
|
111
112
|
devrev_mcp/tools/timeline.py,sha256=p7P699Oq205jrzCVyYS6v4bXHEQizircHVyS5JE-GJ4,5326
|
|
112
113
|
devrev_mcp/tools/users.py,sha256=7CtrxjgxniEb5-DaPh0rwL680a2fDFLJSIU5huuTjYE,4896
|
|
113
|
-
devrev_mcp/tools/works.py,sha256=
|
|
114
|
+
devrev_mcp/tools/works.py,sha256=IFaIXYR4j_2pZ39ykZgdHdo5nCk8OfgWU5bxBdHwp5E,15241
|
|
114
115
|
devrev_mcp/utils/__init__.py,sha256=2_5b1KC5kjoUqFY1ZSdB2Tefd2ekjbZ-eHyFWBKI-0A,49
|
|
115
116
|
devrev_mcp/utils/don_id.py,sha256=PAVzOSgaiqJQSdo1fwohFHq2x7PDNUZoBTN4LFGYM48,6294
|
|
116
117
|
devrev_mcp/utils/errors.py,sha256=5mRAo76rJvvEVi6b1ZokPxDtX5JKkptaqmiYDLCkwBE,2110
|
|
117
118
|
devrev_mcp/utils/formatting.py,sha256=6JssG5x1BxjdgSiQ8Ou3H-9Wo3wgWTWmejsrGez4wKc,2431
|
|
118
119
|
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-
|
|
120
|
+
devrev_python_sdk-3.0.1.dist-info/METADATA,sha256=lTWWDMuQ7SvBTLsT1ape1IhGmY4b8OixjrHpqvkQL2o,40906
|
|
121
|
+
devrev_python_sdk-3.0.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
122
|
+
devrev_python_sdk-3.0.1.dist-info/entry_points.txt,sha256=XiV4J_yy0yzVZVxg7T66YERVIlqdPNp3O-NHTHkllqQ,63
|
|
123
|
+
devrev_python_sdk-3.0.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|