spec-kitty-tracker 0.1.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.
@@ -0,0 +1,114 @@
1
+ """spec-kitty-tracker: universal task tracker interface and sync engine."""
2
+
3
+ from spec_kitty_tracker.capabilities import TrackerCapabilities
4
+ from spec_kitty_tracker.conflicts import ConflictRecord, ConflictStrategy
5
+ from spec_kitty_tracker.connectors import (
6
+ AzureDevOpsConnector,
7
+ AzureDevOpsConnectorConfig,
8
+ BeadsConnector,
9
+ BeadsConnectorConfig,
10
+ FPConnector,
11
+ FPConnectorConfig,
12
+ GitHubConnector,
13
+ GitHubConnectorConfig,
14
+ GitLabConnector,
15
+ GitLabConnectorConfig,
16
+ InMemoryConnector,
17
+ JiraConnector,
18
+ JiraConnectorConfig,
19
+ LinearConnector,
20
+ LinearConnectorConfig,
21
+ )
22
+ from spec_kitty_tracker.errors import (
23
+ CapabilityNotSupportedError,
24
+ FailureClass,
25
+ ConnectorConfigError,
26
+ ConnectorRequestError,
27
+ IssueNotFoundError,
28
+ SpecKittyTrackerError,
29
+ SyncConflictError,
30
+ classify_http_status,
31
+ )
32
+ from spec_kitty_tracker.models import (
33
+ CanonicalIssue,
34
+ CanonicalIssueType,
35
+ CanonicalLink,
36
+ CanonicalStatus,
37
+ ExternalRef,
38
+ LinkType,
39
+ Page,
40
+ SyncCheckpoint,
41
+ TrackerEvent,
42
+ TrackerEventType,
43
+ utcnow,
44
+ )
45
+ from spec_kitty_tracker.mission_sync import (
46
+ BidirectionalIssueSync,
47
+ DecisionReference,
48
+ MissionSeed,
49
+ MissionUpdate,
50
+ mission_seed_from_issue,
51
+ )
52
+ from spec_kitty_tracker.policy import CORE_ISSUE_FIELDS, FieldOwner, OwnershipMode, OwnershipPolicy
53
+ from spec_kitty_tracker.protocols import LocalIssueStore, TaskTrackerConnector
54
+ from spec_kitty_tracker.registry import ConnectorRegistry
55
+ from spec_kitty_tracker.store import InMemoryIssueStore
56
+ from spec_kitty_tracker.sync import SyncEngine, SyncResult, SyncStats
57
+
58
+ __version__ = "0.1.0"
59
+
60
+ __all__ = [
61
+ "AzureDevOpsConnector",
62
+ "AzureDevOpsConnectorConfig",
63
+ "BeadsConnector",
64
+ "BeadsConnectorConfig",
65
+ "BidirectionalIssueSync",
66
+ "CapabilityNotSupportedError",
67
+ "CanonicalIssue",
68
+ "CanonicalIssueType",
69
+ "CanonicalLink",
70
+ "CanonicalStatus",
71
+ "ConflictRecord",
72
+ "ConflictStrategy",
73
+ "ConnectorConfigError",
74
+ "ConnectorRegistry",
75
+ "ConnectorRequestError",
76
+ "CORE_ISSUE_FIELDS",
77
+ "DecisionReference",
78
+ "ExternalRef",
79
+ "FailureClass",
80
+ "FieldOwner",
81
+ "FPConnector",
82
+ "FPConnectorConfig",
83
+ "GitHubConnector",
84
+ "GitHubConnectorConfig",
85
+ "GitLabConnector",
86
+ "GitLabConnectorConfig",
87
+ "InMemoryConnector",
88
+ "InMemoryIssueStore",
89
+ "IssueNotFoundError",
90
+ "JiraConnector",
91
+ "JiraConnectorConfig",
92
+ "LinearConnector",
93
+ "LinearConnectorConfig",
94
+ "LinkType",
95
+ "LocalIssueStore",
96
+ "MissionSeed",
97
+ "MissionUpdate",
98
+ "OwnershipMode",
99
+ "OwnershipPolicy",
100
+ "Page",
101
+ "SpecKittyTrackerError",
102
+ "SyncCheckpoint",
103
+ "SyncConflictError",
104
+ "SyncEngine",
105
+ "SyncResult",
106
+ "SyncStats",
107
+ "classify_http_status",
108
+ "TaskTrackerConnector",
109
+ "TrackerCapabilities",
110
+ "TrackerEvent",
111
+ "TrackerEventType",
112
+ "mission_seed_from_issue",
113
+ "utcnow",
114
+ ]
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass(frozen=True, slots=True)
7
+ class TrackerCapabilities:
8
+ supports_webhooks: bool = False
9
+ supports_comments: bool = True
10
+ supports_hierarchy: bool = True
11
+ supports_dependencies: bool = True
12
+ supports_custom_fields: bool = True
13
+ supports_multi_assignee: bool = True
14
+ supports_sprints_or_cycles: bool = False
15
+ supports_bulk_read: bool = False
16
+ supports_bulk_write: bool = False
17
+ supports_delete: bool = False
18
+
19
+ def as_dict(self) -> dict[str, bool]:
20
+ return {
21
+ "supports_webhooks": self.supports_webhooks,
22
+ "supports_comments": self.supports_comments,
23
+ "supports_hierarchy": self.supports_hierarchy,
24
+ "supports_dependencies": self.supports_dependencies,
25
+ "supports_custom_fields": self.supports_custom_fields,
26
+ "supports_multi_assignee": self.supports_multi_assignee,
27
+ "supports_sprints_or_cycles": self.supports_sprints_or_cycles,
28
+ "supports_bulk_read": self.supports_bulk_read,
29
+ "supports_bulk_write": self.supports_bulk_write,
30
+ "supports_delete": self.supports_delete,
31
+ }
@@ -0,0 +1,94 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime
5
+ from enum import StrEnum
6
+ from typing import Any
7
+
8
+ from spec_kitty_tracker.policy import FieldOwner
9
+
10
+
11
+ class ConflictStrategy(StrEnum):
12
+ EXTERNAL_WINS = "external_wins"
13
+ LOCAL_WINS = "local_wins"
14
+ NEWER_TIMESTAMP = "newer_timestamp"
15
+ MANUAL_REVIEW = "manual_review"
16
+
17
+
18
+ @dataclass(frozen=True, slots=True)
19
+ class ConflictRecord:
20
+ field_name: str
21
+ local_value: Any
22
+ external_value: Any
23
+ resolved_value: Any
24
+ strategy: ConflictStrategy
25
+ manual_review_required: bool = False
26
+
27
+
28
+ @dataclass(frozen=True, slots=True)
29
+ class FieldResolution:
30
+ value: Any
31
+ conflict: ConflictRecord | None
32
+
33
+
34
+ def _prefer_newer(
35
+ local_value: Any,
36
+ external_value: Any,
37
+ local_updated_at: datetime | None,
38
+ external_updated_at: datetime | None,
39
+ ) -> Any:
40
+ if local_updated_at is None and external_updated_at is None:
41
+ return external_value
42
+ if local_updated_at is None:
43
+ return external_value
44
+ if external_updated_at is None:
45
+ return local_value
46
+ if external_updated_at >= local_updated_at:
47
+ return external_value
48
+ return local_value
49
+
50
+
51
+ def resolve_field(
52
+ *,
53
+ field_name: str,
54
+ owner: FieldOwner,
55
+ local_value: Any,
56
+ external_value: Any,
57
+ local_updated_at: datetime | None,
58
+ external_updated_at: datetime | None,
59
+ strategy: ConflictStrategy,
60
+ ) -> FieldResolution:
61
+ if local_value == external_value:
62
+ return FieldResolution(value=local_value, conflict=None)
63
+
64
+ if owner is FieldOwner.LOCAL:
65
+ return FieldResolution(value=local_value, conflict=None)
66
+
67
+ if owner is FieldOwner.EXTERNAL:
68
+ return FieldResolution(value=external_value, conflict=None)
69
+
70
+ manual_review_required = False
71
+ if strategy is ConflictStrategy.EXTERNAL_WINS:
72
+ resolved = external_value
73
+ elif strategy is ConflictStrategy.LOCAL_WINS:
74
+ resolved = local_value
75
+ elif strategy is ConflictStrategy.NEWER_TIMESTAMP:
76
+ resolved = _prefer_newer(
77
+ local_value,
78
+ external_value,
79
+ local_updated_at,
80
+ external_updated_at,
81
+ )
82
+ else:
83
+ resolved = local_value
84
+ manual_review_required = True
85
+
86
+ conflict = ConflictRecord(
87
+ field_name=field_name,
88
+ local_value=local_value,
89
+ external_value=external_value,
90
+ resolved_value=resolved,
91
+ strategy=strategy,
92
+ manual_review_required=manual_review_required,
93
+ )
94
+ return FieldResolution(value=resolved, conflict=conflict)
@@ -0,0 +1,29 @@
1
+ from spec_kitty_tracker.connectors.azure_devops import (
2
+ AzureDevOpsConnector,
3
+ AzureDevOpsConnectorConfig,
4
+ )
5
+ from spec_kitty_tracker.connectors.beads import BeadsConnector, BeadsConnectorConfig
6
+ from spec_kitty_tracker.connectors.fp import FPConnector, FPConnectorConfig
7
+ from spec_kitty_tracker.connectors.github import GitHubConnector, GitHubConnectorConfig
8
+ from spec_kitty_tracker.connectors.gitlab import GitLabConnector, GitLabConnectorConfig
9
+ from spec_kitty_tracker.connectors.in_memory import InMemoryConnector
10
+ from spec_kitty_tracker.connectors.jira import JiraConnector, JiraConnectorConfig
11
+ from spec_kitty_tracker.connectors.linear import LinearConnector, LinearConnectorConfig
12
+
13
+ __all__ = [
14
+ "AzureDevOpsConnector",
15
+ "AzureDevOpsConnectorConfig",
16
+ "BeadsConnector",
17
+ "BeadsConnectorConfig",
18
+ "FPConnector",
19
+ "FPConnectorConfig",
20
+ "GitHubConnector",
21
+ "GitHubConnectorConfig",
22
+ "GitLabConnector",
23
+ "GitLabConnectorConfig",
24
+ "InMemoryConnector",
25
+ "JiraConnector",
26
+ "JiraConnectorConfig",
27
+ "LinearConnector",
28
+ "LinearConnectorConfig",
29
+ ]
@@ -0,0 +1,414 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ from collections.abc import Mapping
5
+ from dataclasses import dataclass, field
6
+ from datetime import datetime
7
+ from typing import Any
8
+
9
+ import httpx
10
+
11
+ from spec_kitty_tracker.capabilities import TrackerCapabilities
12
+ from spec_kitty_tracker.connectors.base_http import HTTPConnectorBase
13
+ from spec_kitty_tracker.errors import ConnectorConfigError
14
+ from spec_kitty_tracker.models import (
15
+ CanonicalIssue,
16
+ CanonicalIssueType,
17
+ CanonicalLink,
18
+ CanonicalStatus,
19
+ ExternalRef,
20
+ LinkType,
21
+ Page,
22
+ SyncCheckpoint,
23
+ TrackerEvent,
24
+ )
25
+
26
+
27
+ @dataclass(frozen=True, slots=True)
28
+ class AzureDevOpsConnectorConfig:
29
+ organization: str
30
+ project: str
31
+ personal_access_token: str
32
+ base_url: str = "https://dev.azure.com"
33
+ status_map: Mapping[CanonicalStatus, str] = field(default_factory=dict)
34
+
35
+
36
+ class AzureDevOpsConnector(HTTPConnectorBase):
37
+ name = "azure_devops"
38
+
39
+ def __init__(
40
+ self,
41
+ config: AzureDevOpsConnectorConfig,
42
+ *,
43
+ client: httpx.AsyncClient | None = None,
44
+ ) -> None:
45
+ if not config.organization.strip() or not config.project.strip():
46
+ raise ConnectorConfigError("Azure organization and project are required")
47
+ self.config = config
48
+ token = base64.b64encode(f":{config.personal_access_token}".encode()).decode()
49
+ headers = {
50
+ "Authorization": f"Basic {token}",
51
+ "Accept": "application/json",
52
+ "Content-Type": "application/json",
53
+ }
54
+ super().__init__(
55
+ base_url=f"{config.base_url.rstrip('/')}/{config.organization}",
56
+ headers=headers,
57
+ client=client,
58
+ )
59
+
60
+ async def get_capabilities(self) -> TrackerCapabilities:
61
+ return TrackerCapabilities(
62
+ supports_webhooks=True,
63
+ supports_comments=False,
64
+ supports_hierarchy=True,
65
+ supports_dependencies=True,
66
+ supports_custom_fields=True,
67
+ supports_multi_assignee=False,
68
+ supports_sprints_or_cycles=True,
69
+ supports_bulk_read=True,
70
+ supports_bulk_write=False,
71
+ supports_delete=False,
72
+ )
73
+
74
+ async def list_issues(
75
+ self,
76
+ *,
77
+ updated_since: datetime | None,
78
+ cursor: str | None,
79
+ limit: int,
80
+ filters: Mapping[str, Any] | None,
81
+ ) -> Page[CanonicalIssue]:
82
+ del cursor, filters
83
+ work_item_ids = await self._query_work_item_ids(updated_since=updated_since, top=limit)
84
+ if not work_item_ids:
85
+ return Page(items=[], next_cursor=None)
86
+
87
+ work_items = await self._get_work_items(work_item_ids)
88
+ issues = [self._to_canonical(item) for item in work_items]
89
+ return Page(items=issues, next_cursor=None)
90
+
91
+ async def get_issue(self, ref: ExternalRef) -> CanonicalIssue:
92
+ payload = await self._request(
93
+ "GET",
94
+ f"/{self.config.project}/_apis/wit/workitems/{ref.id}",
95
+ params={"api-version": "7.1"},
96
+ )
97
+ return self._to_canonical(payload)
98
+
99
+ async def create_issue(self, issue: CanonicalIssue) -> CanonicalIssue:
100
+ work_item_type = self._to_ado_type(issue.issue_type)
101
+ operations: list[dict[str, Any]] = [
102
+ {"op": "add", "path": "/fields/System.Title", "value": issue.title},
103
+ ]
104
+ if issue.body:
105
+ operations.append(
106
+ {"op": "add", "path": "/fields/System.Description", "value": issue.body}
107
+ )
108
+ if issue.priority is not None:
109
+ operations.append(
110
+ {
111
+ "op": "add",
112
+ "path": "/fields/Microsoft.VSTS.Common.Priority",
113
+ "value": issue.priority,
114
+ }
115
+ )
116
+ if issue.labels:
117
+ operations.append(
118
+ {
119
+ "op": "add",
120
+ "path": "/fields/System.Tags",
121
+ "value": "; ".join(issue.labels),
122
+ }
123
+ )
124
+
125
+ payload = await self._request(
126
+ "POST",
127
+ f"/{self.config.project}/_apis/wit/workitems/${work_item_type}",
128
+ params={"api-version": "7.1"},
129
+ headers={"Content-Type": "application/json-patch+json"},
130
+ json_body=operations,
131
+ )
132
+ return self._to_canonical(payload)
133
+
134
+ async def update_issue(
135
+ self,
136
+ ref: ExternalRef,
137
+ patch: Mapping[str, Any],
138
+ *,
139
+ idempotency_key: str | None,
140
+ ) -> CanonicalIssue:
141
+ del idempotency_key
142
+ operations: list[dict[str, Any]] = []
143
+ if "title" in patch:
144
+ operations.append({"op": "add", "path": "/fields/System.Title", "value": patch["title"]})
145
+ if "body" in patch:
146
+ operations.append(
147
+ {"op": "add", "path": "/fields/System.Description", "value": patch["body"]}
148
+ )
149
+ if "priority" in patch and patch["priority"] is not None:
150
+ operations.append(
151
+ {
152
+ "op": "add",
153
+ "path": "/fields/Microsoft.VSTS.Common.Priority",
154
+ "value": patch["priority"],
155
+ }
156
+ )
157
+ if "labels" in patch:
158
+ operations.append(
159
+ {
160
+ "op": "add",
161
+ "path": "/fields/System.Tags",
162
+ "value": "; ".join(list(patch["labels"])),
163
+ }
164
+ )
165
+ if "status" in patch:
166
+ status = CanonicalStatus(str(patch["status"]))
167
+ operations.append(
168
+ {
169
+ "op": "add",
170
+ "path": "/fields/System.State",
171
+ "value": self._to_ado_state(status),
172
+ }
173
+ )
174
+
175
+ if operations:
176
+ await self._request(
177
+ "PATCH",
178
+ f"/{self.config.project}/_apis/wit/workitems/{ref.id}",
179
+ params={"api-version": "7.1"},
180
+ headers={"Content-Type": "application/json-patch+json"},
181
+ json_body=operations,
182
+ )
183
+
184
+ return await self.get_issue(ref)
185
+
186
+ async def transition_issue(
187
+ self,
188
+ ref: ExternalRef,
189
+ target_status: CanonicalStatus,
190
+ ) -> CanonicalIssue:
191
+ return await self.update_issue(
192
+ ref,
193
+ {"status": target_status},
194
+ idempotency_key=f"transition:{ref.identity}:{target_status.value}",
195
+ )
196
+
197
+ async def upsert_link(self, ref: ExternalRef, link: CanonicalLink) -> None:
198
+ relation = {
199
+ "rel": self._ado_relation_type(link.type),
200
+ "url": f"{self.config.base_url.rstrip('/')}/{self.config.organization}/_apis/wit/workItems/{link.target.id}",
201
+ }
202
+ await self._request(
203
+ "PATCH",
204
+ f"/{self.config.project}/_apis/wit/workitems/{ref.id}",
205
+ params={"api-version": "7.1"},
206
+ headers={"Content-Type": "application/json-patch+json"},
207
+ json_body=[{"op": "add", "path": "/relations/-", "value": relation}],
208
+ )
209
+
210
+ async def add_comment(self, ref: ExternalRef, body: str) -> None:
211
+ del ref, body
212
+ raise ConnectorConfigError(
213
+ "AzureDevOpsConnector.add_comment is not enabled in v0.1.0. "
214
+ "Set supports_comments=False and write comments through extension APIs if needed."
215
+ )
216
+
217
+ async def list_events(
218
+ self,
219
+ cursor: SyncCheckpoint | None,
220
+ limit: int,
221
+ ) -> tuple[list[TrackerEvent], SyncCheckpoint | None]:
222
+ del cursor, limit
223
+ return [], None
224
+
225
+ async def _query_work_item_ids(
226
+ self,
227
+ *,
228
+ updated_since: datetime | None,
229
+ top: int,
230
+ ) -> list[int]:
231
+ where_clause = f"[System.TeamProject] = '{self.config.project}'"
232
+ if updated_since is not None:
233
+ where_clause += (
234
+ " AND [System.ChangedDate] >= '"
235
+ + updated_since.strftime("%Y-%m-%dT%H:%M:%SZ")
236
+ + "'"
237
+ )
238
+
239
+ wiql = (
240
+ "SELECT [System.Id] FROM WorkItems "
241
+ f"WHERE {where_clause} "
242
+ "ORDER BY [System.ChangedDate] DESC"
243
+ )
244
+
245
+ payload = await self._request(
246
+ "POST",
247
+ f"/{self.config.project}/_apis/wit/wiql",
248
+ params={"api-version": "7.1"},
249
+ json_body={"query": wiql, "$top": top},
250
+ )
251
+ return [int(item["id"]) for item in payload.get("workItems", [])]
252
+
253
+ async def _get_work_items(self, ids: list[int]) -> list[dict[str, Any]]:
254
+ payload = await self._request(
255
+ "POST",
256
+ f"/{self.config.project}/_apis/wit/workitemsbatch",
257
+ params={"api-version": "7.1"},
258
+ json_body={
259
+ "ids": ids,
260
+ "fields": [
261
+ "System.Id",
262
+ "System.Title",
263
+ "System.Description",
264
+ "System.State",
265
+ "System.WorkItemType",
266
+ "System.AssignedTo",
267
+ "System.Tags",
268
+ "System.CreatedDate",
269
+ "System.ChangedDate",
270
+ "Microsoft.VSTS.Common.Priority",
271
+ ],
272
+ "$expand": "relations",
273
+ },
274
+ )
275
+ return list(payload.get("value", []))
276
+
277
+ def _to_canonical(self, item: Mapping[str, Any]) -> CanonicalIssue:
278
+ fields = item.get("fields", {})
279
+ tags = fields.get("System.Tags")
280
+ labels = [tag.strip() for tag in str(tags).split(";") if tag.strip()] if tags else []
281
+
282
+ assignee = fields.get("System.AssignedTo")
283
+ assignees: list[str] = []
284
+ if isinstance(assignee, Mapping):
285
+ display_name = assignee.get("displayName")
286
+ if display_name:
287
+ assignees = [str(display_name)]
288
+ elif isinstance(assignee, str) and assignee:
289
+ assignees = [assignee]
290
+
291
+ parent = self._extract_parent(item.get("relations", []))
292
+
293
+ return CanonicalIssue(
294
+ ref=ExternalRef(
295
+ system=self.name,
296
+ workspace=f"{self.config.organization}/{self.config.project}",
297
+ id=str(fields.get("System.Id") or item.get("id")),
298
+ key=str(fields.get("System.Id") or item.get("id")),
299
+ url=str(item.get("url") or ""),
300
+ ),
301
+ title=str(fields.get("System.Title") or "Untitled"),
302
+ body=str(fields.get("System.Description")) if fields.get("System.Description") else None,
303
+ status=self._status_from_ado_state(fields.get("System.State")),
304
+ issue_type=self._issue_type_from_ado(fields.get("System.WorkItemType")),
305
+ priority=self._parse_priority(fields.get("Microsoft.VSTS.Common.Priority")),
306
+ assignees=assignees,
307
+ labels=labels,
308
+ parent=parent,
309
+ created_at=self._parse_datetime(fields.get("System.CreatedDate")),
310
+ updated_at=self._parse_datetime(fields.get("System.ChangedDate")),
311
+ raw=dict(item),
312
+ )
313
+
314
+ def _extract_parent(self, relations: Any) -> ExternalRef | None:
315
+ if not isinstance(relations, list):
316
+ return None
317
+ for relation in relations:
318
+ if relation.get("rel") != "System.LinkTypes.Hierarchy-Reverse":
319
+ continue
320
+ url = str(relation.get("url", ""))
321
+ issue_id = url.rsplit("/", 1)[-1]
322
+ if issue_id:
323
+ return ExternalRef(
324
+ system=self.name,
325
+ workspace=f"{self.config.organization}/{self.config.project}",
326
+ id=issue_id,
327
+ key=issue_id,
328
+ url=url,
329
+ )
330
+ return None
331
+
332
+ @staticmethod
333
+ def _parse_datetime(value: Any) -> datetime | None:
334
+ if not isinstance(value, str) or not value:
335
+ return None
336
+ try:
337
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
338
+ except ValueError:
339
+ return None
340
+
341
+ @staticmethod
342
+ def _parse_priority(value: Any) -> int | None:
343
+ if isinstance(value, int):
344
+ return min(max(value, 0), 4)
345
+ try:
346
+ parsed = int(str(value))
347
+ return min(max(parsed, 0), 4)
348
+ except ValueError:
349
+ return None
350
+
351
+ @staticmethod
352
+ def _status_from_ado_state(value: Any) -> CanonicalStatus:
353
+ state = str(value or "").lower()
354
+ if state in {"done", "closed", "resolved"}:
355
+ return CanonicalStatus.DONE
356
+ if state in {"in progress", "active", "committed"}:
357
+ return CanonicalStatus.IN_PROGRESS
358
+ if state in {"blocked", "on hold"}:
359
+ return CanonicalStatus.BLOCKED
360
+ if state in {"review", "in review"}:
361
+ return CanonicalStatus.IN_REVIEW
362
+ if state in {"removed", "canceled", "cancelled"}:
363
+ return CanonicalStatus.CANCELED
364
+ return CanonicalStatus.TODO
365
+
366
+ def _to_ado_state(self, status: CanonicalStatus) -> str:
367
+ if status in self.config.status_map:
368
+ return self.config.status_map[status]
369
+ mapping = {
370
+ CanonicalStatus.TODO: "New",
371
+ CanonicalStatus.IN_PROGRESS: "Active",
372
+ CanonicalStatus.IN_REVIEW: "Resolved",
373
+ CanonicalStatus.BLOCKED: "Active",
374
+ CanonicalStatus.DONE: "Closed",
375
+ CanonicalStatus.CANCELED: "Removed",
376
+ }
377
+ return mapping[status]
378
+
379
+ @staticmethod
380
+ def _issue_type_from_ado(value: Any) -> CanonicalIssueType:
381
+ name = str(value or "").lower()
382
+ if name in {"epic", "feature", "initiative"}:
383
+ return CanonicalIssueType.EPIC
384
+ if name in {"user story", "story"}:
385
+ return CanonicalIssueType.STORY
386
+ if name in {"bug", "defect"}:
387
+ return CanonicalIssueType.BUG
388
+ if name in {"task"}:
389
+ return CanonicalIssueType.TASK
390
+ return CanonicalIssueType.CHORE
391
+
392
+ @staticmethod
393
+ def _to_ado_type(issue_type: CanonicalIssueType) -> str:
394
+ mapping = {
395
+ CanonicalIssueType.EPIC: "Epic",
396
+ CanonicalIssueType.STORY: "User Story",
397
+ CanonicalIssueType.TASK: "Task",
398
+ CanonicalIssueType.BUG: "Bug",
399
+ CanonicalIssueType.CHORE: "Task",
400
+ CanonicalIssueType.SUBTASK: "Task",
401
+ }
402
+ return mapping[issue_type]
403
+
404
+ @staticmethod
405
+ def _ado_relation_type(link_type: LinkType) -> str:
406
+ if link_type is LinkType.BLOCKS:
407
+ return "System.LinkTypes.Dependency-Forward"
408
+ if link_type is LinkType.BLOCKED_BY:
409
+ return "System.LinkTypes.Dependency-Reverse"
410
+ if link_type is LinkType.PARENT_OF:
411
+ return "System.LinkTypes.Hierarchy-Forward"
412
+ if link_type is LinkType.CHILD_OF:
413
+ return "System.LinkTypes.Hierarchy-Reverse"
414
+ return "System.LinkTypes.Related"