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.
- spec_kitty_tracker/__init__.py +114 -0
- spec_kitty_tracker/capabilities.py +31 -0
- spec_kitty_tracker/conflicts.py +94 -0
- spec_kitty_tracker/connectors/__init__.py +29 -0
- spec_kitty_tracker/connectors/azure_devops.py +414 -0
- spec_kitty_tracker/connectors/base_http.py +83 -0
- spec_kitty_tracker/connectors/beads.py +451 -0
- spec_kitty_tracker/connectors/cli_runner.py +39 -0
- spec_kitty_tracker/connectors/fp.py +365 -0
- spec_kitty_tracker/connectors/github.py +248 -0
- spec_kitty_tracker/connectors/gitlab.py +249 -0
- spec_kitty_tracker/connectors/in_memory.py +175 -0
- spec_kitty_tracker/connectors/jira.py +383 -0
- spec_kitty_tracker/connectors/linear.py +353 -0
- spec_kitty_tracker/errors.py +84 -0
- spec_kitty_tracker/mapping.py +37 -0
- spec_kitty_tracker/mission_sync.py +205 -0
- spec_kitty_tracker/models.py +131 -0
- spec_kitty_tracker/policy.py +74 -0
- spec_kitty_tracker/protocols.py +90 -0
- spec_kitty_tracker/py.typed +0 -0
- spec_kitty_tracker/registry.py +39 -0
- spec_kitty_tracker/store.py +27 -0
- spec_kitty_tracker/sync.py +282 -0
- spec_kitty_tracker-0.1.0.dist-info/METADATA +137 -0
- spec_kitty_tracker-0.1.0.dist-info/RECORD +29 -0
- spec_kitty_tracker-0.1.0.dist-info/WHEEL +5 -0
- spec_kitty_tracker-0.1.0.dist-info/licenses/LICENSE +21 -0
- spec_kitty_tracker-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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"
|