devrev-Python-SDK 3.1.0__py3-none-any.whl → 3.2.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/client.py +2 -2
- devrev/models/__init__.py +8 -0
- devrev/models/parts.py +94 -0
- devrev/services/parts.py +437 -2
- devrev/services/works.py +19 -1
- devrev_mcp/tools/parts.py +53 -0
- {devrev_python_sdk-3.1.0.dist-info → devrev_python_sdk-3.2.0.dist-info}/METADATA +55 -4
- {devrev_python_sdk-3.1.0.dist-info → devrev_python_sdk-3.2.0.dist-info}/RECORD +10 -10
- {devrev_python_sdk-3.1.0.dist-info → devrev_python_sdk-3.2.0.dist-info}/WHEEL +0 -0
- {devrev_python_sdk-3.1.0.dist-info → devrev_python_sdk-3.2.0.dist-info}/entry_points.txt +0 -0
devrev/client.py
CHANGED
|
@@ -160,7 +160,7 @@ class DevRevClient:
|
|
|
160
160
|
self._dev_users = DevUsersService(self._http)
|
|
161
161
|
self._groups = GroupsService(self._http)
|
|
162
162
|
self._links = LinksService(self._http)
|
|
163
|
-
self._parts = PartsService(self._http)
|
|
163
|
+
self._parts = PartsService(self._http, parent_client=self)
|
|
164
164
|
self._rev_orgs = RevOrgsService(self._http)
|
|
165
165
|
self._rev_users = RevUsersService(self._http)
|
|
166
166
|
self._slas = SlasService(self._http)
|
|
@@ -488,7 +488,7 @@ class AsyncDevRevClient:
|
|
|
488
488
|
self._dev_users = AsyncDevUsersService(self._http)
|
|
489
489
|
self._groups = AsyncGroupsService(self._http)
|
|
490
490
|
self._links = AsyncLinksService(self._http)
|
|
491
|
-
self._parts = AsyncPartsService(self._http)
|
|
491
|
+
self._parts = AsyncPartsService(self._http, parent_client=self)
|
|
492
492
|
self._rev_orgs = AsyncRevOrgsService(self._http)
|
|
493
493
|
self._rev_users = AsyncRevUsersService(self._http)
|
|
494
494
|
self._slas = AsyncSlasService(self._http)
|
devrev/models/__init__.py
CHANGED
|
@@ -217,6 +217,7 @@ from devrev.models.notifications import (
|
|
|
217
217
|
NotificationsSendResponse,
|
|
218
218
|
)
|
|
219
219
|
from devrev.models.parts import (
|
|
220
|
+
ParentPartFilter,
|
|
220
221
|
Part,
|
|
221
222
|
PartsCreateRequest,
|
|
222
223
|
PartsCreateResponse,
|
|
@@ -226,6 +227,9 @@ from devrev.models.parts import (
|
|
|
226
227
|
PartsGetResponse,
|
|
227
228
|
PartsListRequest,
|
|
228
229
|
PartsListResponse,
|
|
230
|
+
PartsMovePlan,
|
|
231
|
+
PartsMoveRequest,
|
|
232
|
+
PartsMoveResult,
|
|
229
233
|
PartSummary,
|
|
230
234
|
PartsUpdateRequest,
|
|
231
235
|
PartsUpdateResponse,
|
|
@@ -669,6 +673,7 @@ __all__ = [
|
|
|
669
673
|
"NotificationsSendRequest",
|
|
670
674
|
"NotificationsSendResponse",
|
|
671
675
|
# Parts
|
|
676
|
+
"ParentPartFilter",
|
|
672
677
|
"Part",
|
|
673
678
|
"PartSummary",
|
|
674
679
|
"PartType",
|
|
@@ -682,6 +687,9 @@ __all__ = [
|
|
|
682
687
|
"PartsUpdateResponse",
|
|
683
688
|
"PartsDeleteRequest",
|
|
684
689
|
"PartsDeleteResponse",
|
|
690
|
+
"PartsMoveRequest",
|
|
691
|
+
"PartsMovePlan",
|
|
692
|
+
"PartsMoveResult",
|
|
685
693
|
# Preferences
|
|
686
694
|
"Preferences",
|
|
687
695
|
"PreferencesGetRequest",
|
devrev/models/parts.py
CHANGED
|
@@ -11,6 +11,7 @@ from devrev.models.base import (
|
|
|
11
11
|
DevRevBaseModel,
|
|
12
12
|
DevRevResponseModel,
|
|
13
13
|
PaginatedResponse,
|
|
14
|
+
TagWithValue,
|
|
14
15
|
UserSummary,
|
|
15
16
|
)
|
|
16
17
|
|
|
@@ -33,6 +34,7 @@ class Part(DevRevResponseModel):
|
|
|
33
34
|
type: PartType | None = Field(default=None, description="Part type")
|
|
34
35
|
description: str | None = Field(default=None, description="Description")
|
|
35
36
|
owned_by: list[UserSummary] | None = Field(default=None, description="Owners")
|
|
37
|
+
tags: list[TagWithValue] | None = Field(default=None, description="Tags")
|
|
36
38
|
created_date: datetime | None = Field(default=None, description="Creation date")
|
|
37
39
|
modified_date: datetime | None = Field(default=None, description="Last modified")
|
|
38
40
|
|
|
@@ -87,12 +89,32 @@ class PartsDeleteRequest(DevRevBaseModel):
|
|
|
87
89
|
id: str = Field(..., description="Part ID to delete")
|
|
88
90
|
|
|
89
91
|
|
|
92
|
+
class ParentPartFilter(DevRevBaseModel):
|
|
93
|
+
"""Hierarchy filter for ``parts.list`` to fetch parts under given parents.
|
|
94
|
+
|
|
95
|
+
Mirrors the DevRev ``parent_part`` query filter. ``parts`` lists the parent
|
|
96
|
+
part IDs to fetch the hierarchy for (required), and ``level`` optionally
|
|
97
|
+
bounds how many levels of the hierarchy to return (``level=1`` returns the
|
|
98
|
+
direct children of the given parents).
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
parts: list[str] = Field(
|
|
102
|
+
..., min_length=1, description="Parent part IDs to fetch the hierarchy for"
|
|
103
|
+
)
|
|
104
|
+
level: int | None = Field(
|
|
105
|
+
default=None, ge=1, description="Number of levels of the hierarchy to fetch"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
90
109
|
class PartsListRequest(DevRevBaseModel):
|
|
91
110
|
"""Request to list parts."""
|
|
92
111
|
|
|
93
112
|
cursor: str | None = Field(default=None, description="Pagination cursor")
|
|
94
113
|
limit: int | None = Field(default=None, ge=1, le=100, description="Max results")
|
|
95
114
|
type: list[PartType] | None = Field(default=None, description="Filter by type")
|
|
115
|
+
parent_part: ParentPartFilter | None = Field(
|
|
116
|
+
default=None, description="Filter by parent part hierarchy"
|
|
117
|
+
)
|
|
96
118
|
|
|
97
119
|
|
|
98
120
|
class PartsUpdateRequest(DevRevBaseModel):
|
|
@@ -131,3 +153,75 @@ class PartsDeleteResponse(DevRevResponseModel):
|
|
|
131
153
|
"""Response from deleting a part."""
|
|
132
154
|
|
|
133
155
|
pass
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class PartsMoveRequest(DevRevBaseModel):
|
|
159
|
+
"""Request to move (re-parent) a part.
|
|
160
|
+
|
|
161
|
+
The DevRev REST API only accepts ``parent_part`` on ``parts.create``, not
|
|
162
|
+
``parts.update``. This SDK-level convenience request describes a higher-level
|
|
163
|
+
"move" operation that re-parents a part by recreating it under a new parent
|
|
164
|
+
and relinking its dependents. It is NOT a direct DevRev API request body.
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
id: str = Field(..., description="ID of the part to move (re-parent)")
|
|
168
|
+
new_parent_part: str = Field(
|
|
169
|
+
..., description="Target parent part ID under which the part should be moved"
|
|
170
|
+
)
|
|
171
|
+
dry_run: bool = Field(
|
|
172
|
+
default=False,
|
|
173
|
+
description="If True, compute and return the move plan without executing it",
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class PartsMovePlan(DevRevResponseModel):
|
|
178
|
+
"""Plan describing what a part move (re-parent) would do.
|
|
179
|
+
|
|
180
|
+
Computed before (or instead of, for a dry run) executing a move so callers
|
|
181
|
+
can review the dependents that will be relinked or re-parented.
|
|
182
|
+
"""
|
|
183
|
+
|
|
184
|
+
source_part_id: str = Field(..., description="ID of the part to be moved")
|
|
185
|
+
source_part_type: str | None = Field(
|
|
186
|
+
default=None, description="Type of the source part (product, capability, etc.)"
|
|
187
|
+
)
|
|
188
|
+
new_parent_part: str = Field(
|
|
189
|
+
..., description="Target parent part ID the source will be moved under"
|
|
190
|
+
)
|
|
191
|
+
work_items_to_relink: list[str] = Field(
|
|
192
|
+
default_factory=list,
|
|
193
|
+
description="IDs of work items currently applies_to the source part that will be relinked",
|
|
194
|
+
)
|
|
195
|
+
child_parts_to_reparent: list[str] = Field(
|
|
196
|
+
default_factory=list,
|
|
197
|
+
description="IDs of child parts of the source that will be re-parented",
|
|
198
|
+
)
|
|
199
|
+
will_delete_source: bool = Field(
|
|
200
|
+
default=False,
|
|
201
|
+
description="Whether the original source part will be deleted after the move",
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class PartsMoveResult(DevRevResponseModel):
|
|
206
|
+
"""Outcome of an executed (or dry-run) part move (re-parent) operation."""
|
|
207
|
+
|
|
208
|
+
new_part_id: str = Field(..., description="ID of the newly created part under the new parent")
|
|
209
|
+
source_part_id: str = Field(..., description="ID of the original source part that was moved")
|
|
210
|
+
relinked_work_items: list[str] = Field(
|
|
211
|
+
default_factory=list,
|
|
212
|
+
description="IDs of work items that were relinked from the source to the new part",
|
|
213
|
+
)
|
|
214
|
+
reparented_children: list[str] = Field(
|
|
215
|
+
default_factory=list,
|
|
216
|
+
description="IDs of child parts that were re-parented under the new part",
|
|
217
|
+
)
|
|
218
|
+
source_deleted: bool = Field(
|
|
219
|
+
default=False, description="Whether the original source part was deleted"
|
|
220
|
+
)
|
|
221
|
+
dry_run: bool = Field(
|
|
222
|
+
default=False,
|
|
223
|
+
description="Whether this result describes a dry run (no changes were applied)",
|
|
224
|
+
)
|
|
225
|
+
plan: PartsMovePlan = Field(
|
|
226
|
+
..., description="The move plan that was computed for this operation"
|
|
227
|
+
)
|
devrev/services/parts.py
CHANGED
|
@@ -2,7 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from devrev.exceptions import DevRevError
|
|
8
|
+
from devrev.models.base import TagWithValue, UserSummary
|
|
5
9
|
from devrev.models.parts import (
|
|
10
|
+
ParentPartFilter,
|
|
6
11
|
Part,
|
|
7
12
|
PartsCreateRequest,
|
|
8
13
|
PartsCreateResponse,
|
|
@@ -12,11 +17,47 @@ from devrev.models.parts import (
|
|
|
12
17
|
PartsGetResponse,
|
|
13
18
|
PartsListRequest,
|
|
14
19
|
PartsListResponse,
|
|
20
|
+
PartsMovePlan,
|
|
21
|
+
PartsMoveRequest,
|
|
22
|
+
PartsMoveResult,
|
|
15
23
|
PartsUpdateRequest,
|
|
16
24
|
PartsUpdateResponse,
|
|
17
25
|
)
|
|
18
26
|
from devrev.services.base import AsyncBaseService, BaseService
|
|
19
27
|
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from devrev.client import AsyncDevRevClient, DevRevClient
|
|
30
|
+
|
|
31
|
+
# Module-level type alias. Declared here so method-scoped annotations can refer
|
|
32
|
+
# to ``list[str]`` without colliding with the ``list`` method defined on
|
|
33
|
+
# :class:`PartsService` / :class:`AsyncPartsService`.
|
|
34
|
+
_StrList = list[str]
|
|
35
|
+
|
|
36
|
+
# Sentinel returned as ``new_part_id`` for a dry-run move: no part is created,
|
|
37
|
+
# so there is no real ID to report.
|
|
38
|
+
_DRY_RUN_NEW_PART_ID = ""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _owned_by_ids(owned_by: list[UserSummary] | None) -> list[str]:
|
|
42
|
+
"""Map a part's ``owned_by`` UserSummary list to a list of owner IDs."""
|
|
43
|
+
if not owned_by:
|
|
44
|
+
return []
|
|
45
|
+
return [owner.id for owner in owned_by]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _tag_ids(tags: list[TagWithValue] | None) -> list[str]:
|
|
49
|
+
"""Map a part's ``tags`` to a list of tag ID strings for recreation.
|
|
50
|
+
|
|
51
|
+
``TagWithValue.tag`` is a full ``Tag`` object in responses but a tag ID
|
|
52
|
+
string in requests; this normalizes both forms to the ID string.
|
|
53
|
+
"""
|
|
54
|
+
if not tags:
|
|
55
|
+
return []
|
|
56
|
+
ids: list[str] = []
|
|
57
|
+
for tag in tags:
|
|
58
|
+
ids.append(tag.tag if isinstance(tag.tag, str) else tag.tag.id)
|
|
59
|
+
return ids
|
|
60
|
+
|
|
20
61
|
|
|
21
62
|
class PartsService(BaseService):
|
|
22
63
|
"""Service for managing DevRev Parts."""
|
|
@@ -36,17 +77,21 @@ class PartsService(BaseService):
|
|
|
36
77
|
*,
|
|
37
78
|
limit: int | None = None,
|
|
38
79
|
cursor: str | None = None,
|
|
80
|
+
parent_part: ParentPartFilter | None = None,
|
|
39
81
|
) -> PartsListResponse:
|
|
40
82
|
"""List parts.
|
|
41
83
|
|
|
42
84
|
Args:
|
|
43
85
|
limit: Maximum number of results to return (1-100).
|
|
44
86
|
cursor: Pagination cursor from previous response.
|
|
87
|
+
parent_part: Hierarchy filter to return parts under given parents
|
|
88
|
+
(e.g. ``ParentPartFilter(parts=[parent_id], level=1)`` for the
|
|
89
|
+
direct children of ``parent_id``).
|
|
45
90
|
|
|
46
91
|
Returns:
|
|
47
92
|
PartsListResponse with parts and next_cursor for pagination.
|
|
48
93
|
"""
|
|
49
|
-
request = PartsListRequest(limit=limit, cursor=cursor)
|
|
94
|
+
request = PartsListRequest(limit=limit, cursor=cursor, parent_part=parent_part)
|
|
50
95
|
return self._post("/parts.list", request, PartsListResponse)
|
|
51
96
|
|
|
52
97
|
def update(self, request: PartsUpdateRequest) -> Part:
|
|
@@ -58,6 +103,198 @@ class PartsService(BaseService):
|
|
|
58
103
|
"""Delete a part."""
|
|
59
104
|
self._post("/parts.delete", request, PartsDeleteResponse)
|
|
60
105
|
|
|
106
|
+
def _list_child_part_ids(self, source_id: str) -> _StrList:
|
|
107
|
+
"""Return the IDs of the direct child parts of ``source_id``.
|
|
108
|
+
|
|
109
|
+
Pages through ``parts.list`` with a ``parent_part`` hierarchy filter
|
|
110
|
+
(level 1). The source part itself is excluded from the result.
|
|
111
|
+
"""
|
|
112
|
+
child_ids: _StrList = []
|
|
113
|
+
cursor: str | None = None
|
|
114
|
+
while True:
|
|
115
|
+
page = self.list(
|
|
116
|
+
cursor=cursor,
|
|
117
|
+
parent_part=ParentPartFilter(parts=[source_id], level=1),
|
|
118
|
+
)
|
|
119
|
+
for part in page.parts:
|
|
120
|
+
if part.id != source_id:
|
|
121
|
+
child_ids.append(part.id)
|
|
122
|
+
if not page.next_cursor:
|
|
123
|
+
break
|
|
124
|
+
cursor = page.next_cursor
|
|
125
|
+
return child_ids
|
|
126
|
+
|
|
127
|
+
def _collect_descendant_ids(self, source_id: str) -> set[str]:
|
|
128
|
+
"""Return the IDs of every descendant of ``source_id`` (whole subtree).
|
|
129
|
+
|
|
130
|
+
Performs a bounded breadth-first walk over the part hierarchy using the
|
|
131
|
+
existing ``_list_child_part_ids`` (level-1) lookup at each node. A
|
|
132
|
+
``visited`` set guards against re-processing a node, so a malformed
|
|
133
|
+
hierarchy that contains a cycle cannot cause an infinite loop. The
|
|
134
|
+
source itself is never included in the returned set.
|
|
135
|
+
"""
|
|
136
|
+
descendants: set[str] = set()
|
|
137
|
+
visited: set[str] = {source_id}
|
|
138
|
+
frontier: _StrList = [source_id]
|
|
139
|
+
while frontier:
|
|
140
|
+
current = frontier.pop()
|
|
141
|
+
for child_id in self._list_child_part_ids(current):
|
|
142
|
+
if child_id in visited:
|
|
143
|
+
continue
|
|
144
|
+
visited.add(child_id)
|
|
145
|
+
descendants.add(child_id)
|
|
146
|
+
frontier.append(child_id)
|
|
147
|
+
return descendants
|
|
148
|
+
|
|
149
|
+
def _validate_move_target(self, source: Part, request: PartsMoveRequest) -> None:
|
|
150
|
+
"""Reject self-moves and cycles before any mutation occurs.
|
|
151
|
+
|
|
152
|
+
A move whose ``new_parent_part`` is the source itself (self-move) or a
|
|
153
|
+
descendant of the source (cycle) would, under the recreate-and-relink
|
|
154
|
+
workaround, create the replacement part under the soon-to-be-deleted
|
|
155
|
+
source and then delete it -- destroying the new part (data loss). This
|
|
156
|
+
guard runs read-only lookups only and raises before create/relink/delete.
|
|
157
|
+
"""
|
|
158
|
+
if request.new_parent_part == source.id:
|
|
159
|
+
raise DevRevError(
|
|
160
|
+
f"Cannot move part {source.id!r} under itself "
|
|
161
|
+
f"(new_parent_part == id): a self-move would orphan and delete "
|
|
162
|
+
"the recreated part."
|
|
163
|
+
)
|
|
164
|
+
if request.new_parent_part in self._collect_descendant_ids(source.id):
|
|
165
|
+
raise DevRevError(
|
|
166
|
+
f"Cannot move part {source.id!r} under its own descendant "
|
|
167
|
+
f"{request.new_parent_part!r}: this would create a cycle and "
|
|
168
|
+
"destroy the recreated part when the source is deleted."
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
def _list_applies_to_work_ids(self, source_id: str) -> _StrList:
|
|
172
|
+
"""Return the IDs of all work items that apply to ``source_id``.
|
|
173
|
+
|
|
174
|
+
Uses the works service (via the parent client) and paginates fully.
|
|
175
|
+
"""
|
|
176
|
+
works = self._require_parent_client().works
|
|
177
|
+
work_ids: _StrList = []
|
|
178
|
+
cursor: str | None = None
|
|
179
|
+
while True:
|
|
180
|
+
page = works.list(applies_to_part=[source_id], cursor=cursor)
|
|
181
|
+
work_ids.extend(work.id for work in page.works)
|
|
182
|
+
if not page.next_cursor:
|
|
183
|
+
break
|
|
184
|
+
cursor = page.next_cursor
|
|
185
|
+
return work_ids
|
|
186
|
+
|
|
187
|
+
def _require_parent_client(self) -> DevRevClient:
|
|
188
|
+
"""Return the parent client, raising if it was not provided."""
|
|
189
|
+
if not self._parent_client:
|
|
190
|
+
raise DevRevError(
|
|
191
|
+
"move requires a parent client reference. "
|
|
192
|
+
"Ensure the parts service is accessed via DevRevClient.parts."
|
|
193
|
+
)
|
|
194
|
+
return self._parent_client
|
|
195
|
+
|
|
196
|
+
def _compute_move_plan(self, source: Part, request: PartsMoveRequest) -> PartsMovePlan:
|
|
197
|
+
"""Compute the plan describing what moving ``source`` would do."""
|
|
198
|
+
return PartsMovePlan(
|
|
199
|
+
source_part_id=source.id,
|
|
200
|
+
source_part_type=source.type.value if source.type else None,
|
|
201
|
+
new_parent_part=request.new_parent_part,
|
|
202
|
+
work_items_to_relink=self._list_applies_to_work_ids(source.id),
|
|
203
|
+
child_parts_to_reparent=self._list_child_part_ids(source.id),
|
|
204
|
+
will_delete_source=not request.dry_run,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
def move(self, request: PartsMoveRequest) -> PartsMoveResult:
|
|
208
|
+
"""Move (re-parent) a part under a new parent part.
|
|
209
|
+
|
|
210
|
+
The DevRev REST API does not allow changing ``parent_part`` on
|
|
211
|
+
``parts.update`` -- only ``parts.create`` accepts it. This method
|
|
212
|
+
therefore implements a recreate-and-relink workaround: it creates a new
|
|
213
|
+
part of the same type under ``new_parent_part`` (preserving name,
|
|
214
|
+
description, owners and tags), relinks every work item that applied to
|
|
215
|
+
the source onto the new part, re-parents the source's direct children,
|
|
216
|
+
and finally deletes the original source part.
|
|
217
|
+
|
|
218
|
+
Order of operations for a real move: create the new part first, relink
|
|
219
|
+
work items and re-parent children to the new part, then delete the
|
|
220
|
+
source last. If any relink or re-parent fails, the source part is left
|
|
221
|
+
intact (not deleted) so nothing is orphaned and the error is surfaced.
|
|
222
|
+
|
|
223
|
+
Before any mutation (and before computing the plan), the request is
|
|
224
|
+
validated against self-moves and cycles: ``new_parent_part`` may not be
|
|
225
|
+
the source itself nor any descendant of the source. Both checks run
|
|
226
|
+
read-only lookups only and apply to dry runs as well, since such a
|
|
227
|
+
request is invalid regardless of ``dry_run``.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
request: The move request (source id, new parent id, dry_run flag).
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
A fully-populated :class:`PartsMoveResult`. For a dry run,
|
|
234
|
+
``new_part_id`` is an empty-string sentinel, ``source_deleted`` is
|
|
235
|
+
False, and ``plan`` describes what would happen with no mutations.
|
|
236
|
+
|
|
237
|
+
Raises:
|
|
238
|
+
DevRevError: If no parent client is available; if ``new_parent_part``
|
|
239
|
+
is the source itself (self-move) or one of its descendants
|
|
240
|
+
(cycle); or if a relink or re-parent fails (the source is not
|
|
241
|
+
deleted in that case).
|
|
242
|
+
"""
|
|
243
|
+
source = self.get(PartsGetRequest(id=request.id))
|
|
244
|
+
self._validate_move_target(source, request)
|
|
245
|
+
plan = self._compute_move_plan(source, request)
|
|
246
|
+
|
|
247
|
+
if request.dry_run:
|
|
248
|
+
return PartsMoveResult(
|
|
249
|
+
new_part_id=_DRY_RUN_NEW_PART_ID,
|
|
250
|
+
source_part_id=source.id,
|
|
251
|
+
relinked_work_items=[],
|
|
252
|
+
reparented_children=[],
|
|
253
|
+
source_deleted=False,
|
|
254
|
+
dry_run=True,
|
|
255
|
+
plan=plan,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
if source.type is None:
|
|
259
|
+
raise DevRevError(
|
|
260
|
+
f"Cannot move part {source.id!r}: its type is unknown, "
|
|
261
|
+
"so the replacement part cannot be created with the same type."
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
new_part = self.create(
|
|
265
|
+
PartsCreateRequest(
|
|
266
|
+
name=source.name,
|
|
267
|
+
type=source.type,
|
|
268
|
+
description=source.description,
|
|
269
|
+
owned_by=_owned_by_ids(source.owned_by) or None,
|
|
270
|
+
parent_part=[request.new_parent_part],
|
|
271
|
+
tags=_tag_ids(source.tags) or None,
|
|
272
|
+
)
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
works = self._require_parent_client().works
|
|
276
|
+
relinked: _StrList = []
|
|
277
|
+
for work_id in plan.work_items_to_relink:
|
|
278
|
+
works.update(work_id, applies_to_part=new_part.id)
|
|
279
|
+
relinked.append(work_id)
|
|
280
|
+
|
|
281
|
+
reparented: _StrList = []
|
|
282
|
+
for child_id in plan.child_parts_to_reparent:
|
|
283
|
+
self.move(PartsMoveRequest(id=child_id, new_parent_part=new_part.id, dry_run=False))
|
|
284
|
+
reparented.append(child_id)
|
|
285
|
+
|
|
286
|
+
self.delete(PartsDeleteRequest(id=source.id))
|
|
287
|
+
|
|
288
|
+
return PartsMoveResult(
|
|
289
|
+
new_part_id=new_part.id,
|
|
290
|
+
source_part_id=source.id,
|
|
291
|
+
relinked_work_items=relinked,
|
|
292
|
+
reparented_children=reparented,
|
|
293
|
+
source_deleted=True,
|
|
294
|
+
dry_run=False,
|
|
295
|
+
plan=plan,
|
|
296
|
+
)
|
|
297
|
+
|
|
61
298
|
|
|
62
299
|
class AsyncPartsService(AsyncBaseService):
|
|
63
300
|
"""Async service for managing DevRev Parts."""
|
|
@@ -77,17 +314,21 @@ class AsyncPartsService(AsyncBaseService):
|
|
|
77
314
|
*,
|
|
78
315
|
limit: int | None = None,
|
|
79
316
|
cursor: str | None = None,
|
|
317
|
+
parent_part: ParentPartFilter | None = None,
|
|
80
318
|
) -> PartsListResponse:
|
|
81
319
|
"""List parts.
|
|
82
320
|
|
|
83
321
|
Args:
|
|
84
322
|
limit: Maximum number of results to return (1-100).
|
|
85
323
|
cursor: Pagination cursor from previous response.
|
|
324
|
+
parent_part: Hierarchy filter to return parts under given parents
|
|
325
|
+
(e.g. ``ParentPartFilter(parts=[parent_id], level=1)`` for the
|
|
326
|
+
direct children of ``parent_id``).
|
|
86
327
|
|
|
87
328
|
Returns:
|
|
88
329
|
PartsListResponse with parts and next_cursor for pagination.
|
|
89
330
|
"""
|
|
90
|
-
request = PartsListRequest(limit=limit, cursor=cursor)
|
|
331
|
+
request = PartsListRequest(limit=limit, cursor=cursor, parent_part=parent_part)
|
|
91
332
|
return await self._post("/parts.list", request, PartsListResponse)
|
|
92
333
|
|
|
93
334
|
async def update(self, request: PartsUpdateRequest) -> Part:
|
|
@@ -98,3 +339,197 @@ class AsyncPartsService(AsyncBaseService):
|
|
|
98
339
|
async def delete(self, request: PartsDeleteRequest) -> None:
|
|
99
340
|
"""Delete a part."""
|
|
100
341
|
await self._post("/parts.delete", request, PartsDeleteResponse)
|
|
342
|
+
|
|
343
|
+
async def _list_child_part_ids(self, source_id: str) -> _StrList:
|
|
344
|
+
"""Return the IDs of the direct child parts of ``source_id``.
|
|
345
|
+
|
|
346
|
+
Pages through ``parts.list`` with a ``parent_part`` hierarchy filter
|
|
347
|
+
(level 1). The source part itself is excluded from the result.
|
|
348
|
+
"""
|
|
349
|
+
child_ids: _StrList = []
|
|
350
|
+
cursor: str | None = None
|
|
351
|
+
while True:
|
|
352
|
+
page = await self.list(
|
|
353
|
+
cursor=cursor,
|
|
354
|
+
parent_part=ParentPartFilter(parts=[source_id], level=1),
|
|
355
|
+
)
|
|
356
|
+
for part in page.parts:
|
|
357
|
+
if part.id != source_id:
|
|
358
|
+
child_ids.append(part.id)
|
|
359
|
+
if not page.next_cursor:
|
|
360
|
+
break
|
|
361
|
+
cursor = page.next_cursor
|
|
362
|
+
return child_ids
|
|
363
|
+
|
|
364
|
+
async def _collect_descendant_ids(self, source_id: str) -> set[str]:
|
|
365
|
+
"""Return the IDs of every descendant of ``source_id`` (whole subtree).
|
|
366
|
+
|
|
367
|
+
Performs a bounded breadth-first walk over the part hierarchy using the
|
|
368
|
+
existing ``_list_child_part_ids`` (level-1) lookup at each node. A
|
|
369
|
+
``visited`` set guards against re-processing a node, so a malformed
|
|
370
|
+
hierarchy that contains a cycle cannot cause an infinite loop. The
|
|
371
|
+
source itself is never included in the returned set.
|
|
372
|
+
"""
|
|
373
|
+
descendants: set[str] = set()
|
|
374
|
+
visited: set[str] = {source_id}
|
|
375
|
+
frontier: _StrList = [source_id]
|
|
376
|
+
while frontier:
|
|
377
|
+
current = frontier.pop()
|
|
378
|
+
for child_id in await self._list_child_part_ids(current):
|
|
379
|
+
if child_id in visited:
|
|
380
|
+
continue
|
|
381
|
+
visited.add(child_id)
|
|
382
|
+
descendants.add(child_id)
|
|
383
|
+
frontier.append(child_id)
|
|
384
|
+
return descendants
|
|
385
|
+
|
|
386
|
+
async def _validate_move_target(self, source: Part, request: PartsMoveRequest) -> None:
|
|
387
|
+
"""Reject self-moves and cycles before any mutation occurs.
|
|
388
|
+
|
|
389
|
+
A move whose ``new_parent_part`` is the source itself (self-move) or a
|
|
390
|
+
descendant of the source (cycle) would, under the recreate-and-relink
|
|
391
|
+
workaround, create the replacement part under the soon-to-be-deleted
|
|
392
|
+
source and then delete it -- destroying the new part (data loss). This
|
|
393
|
+
guard runs read-only lookups only and raises before create/relink/delete.
|
|
394
|
+
"""
|
|
395
|
+
if request.new_parent_part == source.id:
|
|
396
|
+
raise DevRevError(
|
|
397
|
+
f"Cannot move part {source.id!r} under itself "
|
|
398
|
+
f"(new_parent_part == id): a self-move would orphan and delete "
|
|
399
|
+
"the recreated part."
|
|
400
|
+
)
|
|
401
|
+
if request.new_parent_part in await self._collect_descendant_ids(source.id):
|
|
402
|
+
raise DevRevError(
|
|
403
|
+
f"Cannot move part {source.id!r} under its own descendant "
|
|
404
|
+
f"{request.new_parent_part!r}: this would create a cycle and "
|
|
405
|
+
"destroy the recreated part when the source is deleted."
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
async def _list_applies_to_work_ids(self, source_id: str) -> _StrList:
|
|
409
|
+
"""Return the IDs of all work items that apply to ``source_id``.
|
|
410
|
+
|
|
411
|
+
Uses the works service (via the parent client) and paginates fully.
|
|
412
|
+
"""
|
|
413
|
+
works = self._require_parent_client().works
|
|
414
|
+
work_ids: _StrList = []
|
|
415
|
+
cursor: str | None = None
|
|
416
|
+
while True:
|
|
417
|
+
page = await works.list(applies_to_part=[source_id], cursor=cursor)
|
|
418
|
+
work_ids.extend(work.id for work in page.works)
|
|
419
|
+
if not page.next_cursor:
|
|
420
|
+
break
|
|
421
|
+
cursor = page.next_cursor
|
|
422
|
+
return work_ids
|
|
423
|
+
|
|
424
|
+
def _require_parent_client(self) -> AsyncDevRevClient:
|
|
425
|
+
"""Return the parent client, raising if it was not provided."""
|
|
426
|
+
if not self._parent_client:
|
|
427
|
+
raise DevRevError(
|
|
428
|
+
"move requires a parent client reference. "
|
|
429
|
+
"Ensure the parts service is accessed via AsyncDevRevClient.parts."
|
|
430
|
+
)
|
|
431
|
+
return self._parent_client
|
|
432
|
+
|
|
433
|
+
async def _compute_move_plan(self, source: Part, request: PartsMoveRequest) -> PartsMovePlan:
|
|
434
|
+
"""Compute the plan describing what moving ``source`` would do."""
|
|
435
|
+
return PartsMovePlan(
|
|
436
|
+
source_part_id=source.id,
|
|
437
|
+
source_part_type=source.type.value if source.type else None,
|
|
438
|
+
new_parent_part=request.new_parent_part,
|
|
439
|
+
work_items_to_relink=await self._list_applies_to_work_ids(source.id),
|
|
440
|
+
child_parts_to_reparent=await self._list_child_part_ids(source.id),
|
|
441
|
+
will_delete_source=not request.dry_run,
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
async def move(self, request: PartsMoveRequest) -> PartsMoveResult:
|
|
445
|
+
"""Move (re-parent) a part under a new parent part.
|
|
446
|
+
|
|
447
|
+
The DevRev REST API does not allow changing ``parent_part`` on
|
|
448
|
+
``parts.update`` -- only ``parts.create`` accepts it. This method
|
|
449
|
+
therefore implements a recreate-and-relink workaround: it creates a new
|
|
450
|
+
part of the same type under ``new_parent_part`` (preserving name,
|
|
451
|
+
description, owners and tags), relinks every work item that applied to
|
|
452
|
+
the source onto the new part, re-parents the source's direct children,
|
|
453
|
+
and finally deletes the original source part.
|
|
454
|
+
|
|
455
|
+
Order of operations for a real move: create the new part first, relink
|
|
456
|
+
work items and re-parent children to the new part, then delete the
|
|
457
|
+
source last. If any relink or re-parent fails, the source part is left
|
|
458
|
+
intact (not deleted) so nothing is orphaned and the error is surfaced.
|
|
459
|
+
|
|
460
|
+
Before any mutation (and before computing the plan), the request is
|
|
461
|
+
validated against self-moves and cycles: ``new_parent_part`` may not be
|
|
462
|
+
the source itself nor any descendant of the source. Both checks run
|
|
463
|
+
read-only lookups only and apply to dry runs as well, since such a
|
|
464
|
+
request is invalid regardless of ``dry_run``.
|
|
465
|
+
|
|
466
|
+
Args:
|
|
467
|
+
request: The move request (source id, new parent id, dry_run flag).
|
|
468
|
+
|
|
469
|
+
Returns:
|
|
470
|
+
A fully-populated :class:`PartsMoveResult`. For a dry run,
|
|
471
|
+
``new_part_id`` is an empty-string sentinel, ``source_deleted`` is
|
|
472
|
+
False, and ``plan`` describes what would happen with no mutations.
|
|
473
|
+
|
|
474
|
+
Raises:
|
|
475
|
+
DevRevError: If no parent client is available; if ``new_parent_part``
|
|
476
|
+
is the source itself (self-move) or one of its descendants
|
|
477
|
+
(cycle); or if a relink or re-parent fails (the source is not
|
|
478
|
+
deleted in that case).
|
|
479
|
+
"""
|
|
480
|
+
source = await self.get(PartsGetRequest(id=request.id))
|
|
481
|
+
await self._validate_move_target(source, request)
|
|
482
|
+
plan = await self._compute_move_plan(source, request)
|
|
483
|
+
|
|
484
|
+
if request.dry_run:
|
|
485
|
+
return PartsMoveResult(
|
|
486
|
+
new_part_id=_DRY_RUN_NEW_PART_ID,
|
|
487
|
+
source_part_id=source.id,
|
|
488
|
+
relinked_work_items=[],
|
|
489
|
+
reparented_children=[],
|
|
490
|
+
source_deleted=False,
|
|
491
|
+
dry_run=True,
|
|
492
|
+
plan=plan,
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
if source.type is None:
|
|
496
|
+
raise DevRevError(
|
|
497
|
+
f"Cannot move part {source.id!r}: its type is unknown, "
|
|
498
|
+
"so the replacement part cannot be created with the same type."
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
new_part = await self.create(
|
|
502
|
+
PartsCreateRequest(
|
|
503
|
+
name=source.name,
|
|
504
|
+
type=source.type,
|
|
505
|
+
description=source.description,
|
|
506
|
+
owned_by=_owned_by_ids(source.owned_by) or None,
|
|
507
|
+
parent_part=[request.new_parent_part],
|
|
508
|
+
tags=_tag_ids(source.tags) or None,
|
|
509
|
+
)
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
works = self._require_parent_client().works
|
|
513
|
+
relinked: _StrList = []
|
|
514
|
+
for work_id in plan.work_items_to_relink:
|
|
515
|
+
await works.update(work_id, applies_to_part=new_part.id)
|
|
516
|
+
relinked.append(work_id)
|
|
517
|
+
|
|
518
|
+
reparented: _StrList = []
|
|
519
|
+
for child_id in plan.child_parts_to_reparent:
|
|
520
|
+
await self.move(
|
|
521
|
+
PartsMoveRequest(id=child_id, new_parent_part=new_part.id, dry_run=False)
|
|
522
|
+
)
|
|
523
|
+
reparented.append(child_id)
|
|
524
|
+
|
|
525
|
+
await self.delete(PartsDeleteRequest(id=source.id))
|
|
526
|
+
|
|
527
|
+
return PartsMoveResult(
|
|
528
|
+
new_part_id=new_part.id,
|
|
529
|
+
source_part_id=source.id,
|
|
530
|
+
relinked_work_items=relinked,
|
|
531
|
+
reparented_children=reparented,
|
|
532
|
+
source_deleted=True,
|
|
533
|
+
dry_run=False,
|
|
534
|
+
plan=plan,
|
|
535
|
+
)
|
devrev/services/works.py
CHANGED
|
@@ -194,6 +194,7 @@ class WorksService(BaseService):
|
|
|
194
194
|
*,
|
|
195
195
|
title: str | None = None,
|
|
196
196
|
body: str | None = None,
|
|
197
|
+
applies_to_part: str | None = None,
|
|
197
198
|
owned_by: Sequence[str] | None = None,
|
|
198
199
|
stage: StageUpdate | None = None,
|
|
199
200
|
priority: IssuePriority | None = None,
|
|
@@ -206,6 +207,7 @@ class WorksService(BaseService):
|
|
|
206
207
|
id: Work item ID
|
|
207
208
|
title: New title
|
|
208
209
|
body: New body/description
|
|
210
|
+
applies_to_part: New part ID this work applies to
|
|
209
211
|
owned_by: New owner IDs
|
|
210
212
|
stage: New stage
|
|
211
213
|
priority: New priority (for issues)
|
|
@@ -220,6 +222,7 @@ class WorksService(BaseService):
|
|
|
220
222
|
id=id,
|
|
221
223
|
title=title,
|
|
222
224
|
body=body,
|
|
225
|
+
applies_to_part=applies_to_part,
|
|
223
226
|
owned_by=owned_by_update,
|
|
224
227
|
stage=stage,
|
|
225
228
|
priority=priority,
|
|
@@ -471,16 +474,31 @@ class AsyncWorksService(AsyncBaseService):
|
|
|
471
474
|
*,
|
|
472
475
|
title: str | None = None,
|
|
473
476
|
body: str | None = None,
|
|
477
|
+
applies_to_part: str | None = None,
|
|
474
478
|
owned_by: Sequence[str] | None = None,
|
|
475
479
|
priority: IssuePriority | None = None,
|
|
476
480
|
severity: TicketSeverity | None = None,
|
|
477
481
|
) -> Work:
|
|
478
|
-
"""Update a work item.
|
|
482
|
+
"""Update a work item.
|
|
483
|
+
|
|
484
|
+
Args:
|
|
485
|
+
id: Work item ID
|
|
486
|
+
title: New title
|
|
487
|
+
body: New body/description
|
|
488
|
+
applies_to_part: New part ID this work applies to
|
|
489
|
+
owned_by: New owner IDs
|
|
490
|
+
priority: New priority (for issues)
|
|
491
|
+
severity: New severity (for tickets)
|
|
492
|
+
|
|
493
|
+
Returns:
|
|
494
|
+
The updated Work item
|
|
495
|
+
"""
|
|
479
496
|
owned_by_update = WorksUpdateRequestOwnedBy(set=owned_by) if owned_by else None
|
|
480
497
|
request = WorksUpdateRequest(
|
|
481
498
|
id=id,
|
|
482
499
|
title=title,
|
|
483
500
|
body=body,
|
|
501
|
+
applies_to_part=applies_to_part,
|
|
484
502
|
owned_by=owned_by_update,
|
|
485
503
|
priority=priority,
|
|
486
504
|
severity=severity,
|
devrev_mcp/tools/parts.py
CHANGED
|
@@ -16,6 +16,7 @@ from devrev.models.parts import (
|
|
|
16
16
|
PartsCreateRequest,
|
|
17
17
|
PartsDeleteRequest,
|
|
18
18
|
PartsGetRequest,
|
|
19
|
+
PartsMoveRequest,
|
|
19
20
|
PartsUpdateRequest,
|
|
20
21
|
PartType,
|
|
21
22
|
)
|
|
@@ -191,3 +192,55 @@ if _config.enable_destructive_tools:
|
|
|
191
192
|
return {"success": True, "message": f"Part {id} deleted successfully"}
|
|
192
193
|
except DevRevError as e:
|
|
193
194
|
raise RuntimeError(format_devrev_error(e)) from e
|
|
195
|
+
|
|
196
|
+
@mcp.tool()
|
|
197
|
+
async def devrev_parts_move(
|
|
198
|
+
ctx: Context[Any, Any, Any],
|
|
199
|
+
id: str,
|
|
200
|
+
new_parent_part: str,
|
|
201
|
+
dry_run: bool = False,
|
|
202
|
+
) -> dict[str, Any]:
|
|
203
|
+
"""Move (re-parent) a DevRev part under a new parent part.
|
|
204
|
+
|
|
205
|
+
This is NOT an in-place update. The DevRev REST API does not allow
|
|
206
|
+
changing a part's parent, so this operation RECREATES the part under the
|
|
207
|
+
new parent (giving it a NEW id), relinks dependents (work items that
|
|
208
|
+
apply to the source part and child parts of the source), and then, on a
|
|
209
|
+
real run, DELETES the original source part.
|
|
210
|
+
|
|
211
|
+
When ``dry_run`` is True, the move plan is computed and returned WITHOUT
|
|
212
|
+
mutating anything: no part is created, no dependents are relinked, and
|
|
213
|
+
the source part is not deleted. Use a dry run to review the impact
|
|
214
|
+
(``plan.work_items_to_relink``, ``plan.child_parts_to_reparent``,
|
|
215
|
+
``plan.will_delete_source``) before performing a real move.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
id: The ID of the part to move (re-parent). This part is recreated
|
|
219
|
+
under the new parent and, on a real run, deleted.
|
|
220
|
+
new_parent_part: The ID of the target parent part to move under.
|
|
221
|
+
dry_run: If True, return the computed move plan without applying any
|
|
222
|
+
changes. Defaults to False (perform the move).
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
Dictionary containing the serialized move result, including the
|
|
226
|
+
new part ID, the original source part ID, the relinked work items,
|
|
227
|
+
the re-parented child parts, whether the source was deleted, the
|
|
228
|
+
dry-run flag, and the nested move plan.
|
|
229
|
+
|
|
230
|
+
Raises:
|
|
231
|
+
RuntimeError: If a provided ID is not a valid part DON ID, or if the
|
|
232
|
+
DevRev API call fails.
|
|
233
|
+
"""
|
|
234
|
+
validate_don_id(id, "part", "devrev_parts_move")
|
|
235
|
+
validate_don_id(new_parent_part, "part", "devrev_parts_move")
|
|
236
|
+
app = ctx.request_context.lifespan_context
|
|
237
|
+
try:
|
|
238
|
+
request = PartsMoveRequest(
|
|
239
|
+
id=id,
|
|
240
|
+
new_parent_part=new_parent_part,
|
|
241
|
+
dry_run=dry_run,
|
|
242
|
+
)
|
|
243
|
+
result = await app.get_client().parts.move(request)
|
|
244
|
+
return serialize_model(result)
|
|
245
|
+
except DevRevError as e:
|
|
246
|
+
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.
|
|
3
|
+
Version: 3.2.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
|
|
@@ -505,6 +505,57 @@ export_result = client.works.export(
|
|
|
505
505
|
)
|
|
506
506
|
```
|
|
507
507
|
|
|
508
|
+
### Managing Parts
|
|
509
|
+
|
|
510
|
+
```python
|
|
511
|
+
from devrev.models.parts import PartsCreateRequest, PartsMoveRequest, PartType
|
|
512
|
+
|
|
513
|
+
# Create a feature under a capability
|
|
514
|
+
feature = client.parts.create(PartsCreateRequest(
|
|
515
|
+
name="Bulk import",
|
|
516
|
+
type=PartType.FEATURE,
|
|
517
|
+
parent_part=["don:core:dvrv-us-1:devo/1:part/1"],
|
|
518
|
+
))
|
|
519
|
+
|
|
520
|
+
# Move (re-parent) a part under a new parent part
|
|
521
|
+
#
|
|
522
|
+
# The DevRev REST API only accepts `parent_part` on parts.create (not
|
|
523
|
+
# parts.update), so move() implements a recreate-and-relink workaround: it
|
|
524
|
+
# creates a new same-type part under the new parent (preserving name,
|
|
525
|
+
# description, owners and tags), relinks work items (applies_to_part) and child
|
|
526
|
+
# parts onto the new part, then deletes the original source part.
|
|
527
|
+
#
|
|
528
|
+
# CAVEAT: a real move is NOT an in-place update. It produces a NEW part id and
|
|
529
|
+
# DELETES the original part. Update any references you hold to the old id.
|
|
530
|
+
|
|
531
|
+
# Dry run first: returns the plan, mutates nothing.
|
|
532
|
+
plan = client.parts.move(PartsMoveRequest(
|
|
533
|
+
id=feature.id,
|
|
534
|
+
new_parent_part="don:core:dvrv-us-1:devo/1:part/2",
|
|
535
|
+
dry_run=True,
|
|
536
|
+
))
|
|
537
|
+
print(plan.plan.work_items_to_relink) # work items that would be relinked
|
|
538
|
+
print(plan.plan.child_parts_to_reparent) # child parts that would be re-parented
|
|
539
|
+
print(plan.plan.will_delete_source) # True -> the source part will be deleted
|
|
540
|
+
assert plan.source_deleted is False # nothing was changed by the dry run
|
|
541
|
+
|
|
542
|
+
# Real move: creates the new part, relinks dependents, deletes the source.
|
|
543
|
+
result = client.parts.move(PartsMoveRequest(
|
|
544
|
+
id=feature.id,
|
|
545
|
+
new_parent_part="don:core:dvrv-us-1:devo/1:part/2",
|
|
546
|
+
))
|
|
547
|
+
print(result.new_part_id) # the NEW part id (the source id is now deleted)
|
|
548
|
+
print(result.relinked_work_items)
|
|
549
|
+
print(result.reparented_children)
|
|
550
|
+
print(result.source_deleted) # True
|
|
551
|
+
```
|
|
552
|
+
|
|
553
|
+
> **Note**: `move` requires the parts service be accessed via the client
|
|
554
|
+
> (`client.parts`) because it coordinates with the works service to relink work
|
|
555
|
+
> items. Via the MCP server, this is the `devrev_parts_move` tool, which is
|
|
556
|
+
> gated behind `MCP_ENABLE_DESTRUCTIVE_TOOLS=true` (it creates and deletes
|
|
557
|
+
> parts).
|
|
558
|
+
|
|
508
559
|
### Articles and Knowledge Base
|
|
509
560
|
|
|
510
561
|
```python
|
|
@@ -995,7 +1046,7 @@ All settings are configurable via environment variables (prefix `MCP_`):
|
|
|
995
1046
|
| Users | 5 | dev list/get, rev list/get/create |
|
|
996
1047
|
| Conversations | 6 | list, get, create, update, delete, export |
|
|
997
1048
|
| Articles | 6 | list, get, create, update, delete, count |
|
|
998
|
-
| Parts |
|
|
1049
|
+
| Parts | 6 | list, get, create, update, delete, move |
|
|
999
1050
|
| Tags | 5 | list, get, create, update, delete |
|
|
1000
1051
|
| Groups | 8 | list, get, create, update, delete, add/remove member, count |
|
|
1001
1052
|
| Timeline | 5 | list, get, create, update, delete |
|
|
@@ -1028,13 +1079,13 @@ py-devrev/
|
|
|
1028
1079
|
│ ├── __main__.py # CLI entry point
|
|
1029
1080
|
│ ├── server.py # FastMCP server setup & lifecycle
|
|
1030
1081
|
│ ├── config.py # MCPServerConfig (pydantic-settings)
|
|
1031
|
-
│ ├── tools/ #
|
|
1082
|
+
│ ├── tools/ # 79 MCP tools across 15 categories
|
|
1032
1083
|
│ │ ├── works.py # Works: list, get, create, update, delete, count, search
|
|
1033
1084
|
│ │ ├── accounts.py # Accounts: list, get, create, update, delete, merge
|
|
1034
1085
|
│ │ ├── users.py # Users: list/get dev users, list/get rev users, whoami
|
|
1035
1086
|
│ │ ├── conversations.py # Conversations: list, get, create, update, delete, export
|
|
1036
1087
|
│ │ ├── articles.py # Articles: list, get, create, update, delete, count
|
|
1037
|
-
│ │ ├── parts.py # Parts: list, get, create, update, delete
|
|
1088
|
+
│ │ ├── parts.py # Parts: list, get, create, update, delete, move
|
|
1038
1089
|
│ │ ├── tags.py # Tags: list, get, create, update, delete
|
|
1039
1090
|
│ │ ├── groups.py # Groups: CRUD + member management + count
|
|
1040
1091
|
│ │ ├── timeline.py # Timeline: list, get, create, update, delete
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
devrev/__init__.py,sha256=iBKIRvS10mFWpBgnMjQQiQR04rgb8ZyXVk1-7cYJikI,1555
|
|
2
|
-
devrev/client.py,sha256=
|
|
2
|
+
devrev/client.py,sha256=WfbWOWJo34bBzh2FwAsJfrWMwHSPTHINYbDUI4eE-Dk,26498
|
|
3
3
|
devrev/config.py,sha256=BKFhD8lMJvq-I9kpchiA50fYefVFhnNwcK9ReFogh4k,8122
|
|
4
4
|
devrev/exceptions.py,sha256=Bs0pYgOgG26cyI785rdJeTR72gM-rXHaCEt24HAJzEk,7540
|
|
5
5
|
devrev/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
6
|
-
devrev/models/__init__.py,sha256=
|
|
6
|
+
devrev/models/__init__.py,sha256=0R2_Khol0gx7AYMKLLge5CMGKv2h6wCd31CQqG8-j_g,24209
|
|
7
7
|
devrev/models/accounts.py,sha256=LrXAit2gIr34XyRO1T0fL6EwdXHsfJOyXwcegPnKnaE,6974
|
|
8
8
|
devrev/models/articles.py,sha256=DgrZC_wtLsXIfilAvHJBoQkhbGDg3Vx5UX7h9MD63xA,16732
|
|
9
9
|
devrev/models/artifacts.py,sha256=3iA58RyVlX0S0U5iID3Cnoc096Y70QfGqYfKEhxfE9g,4375
|
|
@@ -17,7 +17,7 @@ devrev/models/groups.py,sha256=oB3eCuX14po5E8WhqkuuC-GlC1WXEPVfpqB5tn4NZ-o,4486
|
|
|
17
17
|
devrev/models/incidents.py,sha256=e8Wq7MyXeDPiYMCNvxPlZa4D939196fUaND-MYtBbcw,6305
|
|
18
18
|
devrev/models/links.py,sha256=0jtIXn90WQgGrSrnR72AgM9lMVSK6xv9fXKzq2Eki-A,2963
|
|
19
19
|
devrev/models/notifications.py,sha256=BjVGx6XR1rQbQPR1hz05j5wU-8VMqH2m02KZmMl3Z98,867
|
|
20
|
-
devrev/models/parts.py,sha256=
|
|
20
|
+
devrev/models/parts.py,sha256=PcE_N3Gd9YRhBoXwgYVhXYsVtVMAzM_nIcGEQ3Ut2Hc,8003
|
|
21
21
|
devrev/models/preferences.py,sha256=S98RoSJhSlBavzFotyqsx_r1LGS2WjRtmazN4yIQFwc,1806
|
|
22
22
|
devrev/models/question_answers.py,sha256=lPgE-OXzxMkrQdQE4NPz8IRV0I5P8iJq7VVdpx4iovg,3606
|
|
23
23
|
devrev/models/recommendations.py,sha256=4szM-gtm-oVN7lBqi1xtGKABJcdwTeFLF_iR1fUoImY,2727
|
|
@@ -50,7 +50,7 @@ devrev/services/groups.py,sha256=0VpOKTwHiRjrKXrO1kdHo8V_z2I02GphYQhyDFnImxg,496
|
|
|
50
50
|
devrev/services/incidents.py,sha256=YhrSj89FTVXI7xEw_vm0SMR5iIrnvhwu4MRdQqqhBx8,8076
|
|
51
51
|
devrev/services/links.py,sha256=e6ppPQzdIp2tkJ_VEpkNhACZUq2scu2EfCr1S7NPh9s,2266
|
|
52
52
|
devrev/services/notifications.py,sha256=uZZrNfpfEFOMChoAeVZR-Ow2NtPBgAtZ5Qv2mPN6Fvw,2201
|
|
53
|
-
devrev/services/parts.py,sha256=
|
|
53
|
+
devrev/services/parts.py,sha256=EQbuhDFIPKcCxljTTaLe5fo1gdwNhmmk0ZsBH8x8sj4,22120
|
|
54
54
|
devrev/services/preferences.py,sha256=MXdBsPcAUcCO14hKrIjhHkFj_XD1OZbtTHnOGZaWTwg,3327
|
|
55
55
|
devrev/services/question_answers.py,sha256=bDgbtjI3RCHjJLwI7JsSgvxNh7OITNQZrNigq9CqL8E,4332
|
|
56
56
|
devrev/services/recommendations.py,sha256=_UV9BBlxwfrek69Q5LzSZRE2D43FR3BSWUhRMDHeXWM,1962
|
|
@@ -63,7 +63,7 @@ devrev/services/timeline_entries.py,sha256=6nbVPcWlE3-ohLhvkM421eqYS5ztKtHmGsPek
|
|
|
63
63
|
devrev/services/track_events.py,sha256=lI4wXkWu3uUuXtuRg1MGNkTZ7B0Lc1PjM8Kw-6sUnWc,1439
|
|
64
64
|
devrev/services/uoms.py,sha256=AA3ymoHj24FIbsZpYC4tg2elSdQ3iINTVOz7MraZcj8,8163
|
|
65
65
|
devrev/services/webhooks.py,sha256=-TSkcaya1y48WB24_vHd-bqO5xSqxRsCLiilncNzQZU,3917
|
|
66
|
-
devrev/services/works.py,sha256=
|
|
66
|
+
devrev/services/works.py,sha256=wUVQ8EcBs7Ogn2x219G37HL_WTYI0TCdxn-KPOD3IaQ,21064
|
|
67
67
|
devrev/utils/__init__.py,sha256=NOrbpkjDVLH8n9xf-xpZJiIIa_GVI_6vqTm3E8L3Udw,857
|
|
68
68
|
devrev/utils/content_converter.py,sha256=emRBLiVoOfDGpPDzrMRnqQr4-QkqN13OdWlYOyU_LCg,28141
|
|
69
69
|
devrev/utils/deprecation.py,sha256=7qB2Dx531oP7mNi7q2txOYsOKC9YwdHqlKPMFHOW9Ws,1275
|
|
@@ -101,7 +101,7 @@ devrev_mcp/tools/engagements.py,sha256=21zsMLlgY1FwKzSIxPf6XVdMY77p8NXrWZ7l8PRKI
|
|
|
101
101
|
devrev_mcp/tools/groups.py,sha256=E29eptwl_1rbmMPPElcSblWs71t1XdvSZIl1UR8TZr0,8456
|
|
102
102
|
devrev_mcp/tools/incidents.py,sha256=XO9FaZlLHCXULCFpMpCOftYNfvajH5PeXOkW2uUZ_Z4,7984
|
|
103
103
|
devrev_mcp/tools/links.py,sha256=oma8sLKaz0i3BnQmdoXdneZJx62j2P5YHbLag2aLNkU,4230
|
|
104
|
-
devrev_mcp/tools/parts.py,sha256=
|
|
104
|
+
devrev_mcp/tools/parts.py,sha256=nyEBYznHbhAzR0zPu0-f24zLRKrTKdXpb6luQ4cZ-_I,8906
|
|
105
105
|
devrev_mcp/tools/question_answers.py,sha256=57p4bZVQ52wGseBAamI-2qJoiZVbMRo46NNuOtGGhOo,5272
|
|
106
106
|
devrev_mcp/tools/recommendations.py,sha256=Cg1NeDYgIlWLrtFYuxGlvQLpy6uDm2HoG91yGoSRC8A,3014
|
|
107
107
|
devrev_mcp/tools/rev_orgs.py,sha256=Uge-rORmFTHOD1hhYy7pDLaGSxtisJsnn5ccazldfpU,5074
|
|
@@ -118,7 +118,7 @@ devrev_mcp/utils/don_id.py,sha256=jbVKXW_BeKjPHhz1F9p14fNmrXP2R36rgWZqdC4QlOc,63
|
|
|
118
118
|
devrev_mcp/utils/errors.py,sha256=5mRAo76rJvvEVi6b1ZokPxDtX5JKkptaqmiYDLCkwBE,2110
|
|
119
119
|
devrev_mcp/utils/formatting.py,sha256=6JssG5x1BxjdgSiQ8Ou3H-9Wo3wgWTWmejsrGez4wKc,2431
|
|
120
120
|
devrev_mcp/utils/pagination.py,sha256=EOUgL-ZdSToM1Q-ydXmjhibsef5K1u1g3CaS9K8I2fY,1286
|
|
121
|
-
devrev_python_sdk-3.
|
|
122
|
-
devrev_python_sdk-3.
|
|
123
|
-
devrev_python_sdk-3.
|
|
124
|
-
devrev_python_sdk-3.
|
|
121
|
+
devrev_python_sdk-3.2.0.dist-info/METADATA,sha256=boi9nuE-abiG-LLrdoLGjLyJLWZ5u9Vb21rG_1jViQQ,43054
|
|
122
|
+
devrev_python_sdk-3.2.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
123
|
+
devrev_python_sdk-3.2.0.dist-info/entry_points.txt,sha256=XiV4J_yy0yzVZVxg7T66YERVIlqdPNp3O-NHTHkllqQ,63
|
|
124
|
+
devrev_python_sdk-3.2.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|