workforge 2.4.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- workforge/__init__.py +5 -0
- workforge/cli.py +733 -0
- workforge/config.py +47 -0
- workforge/core/__init__.py +1 -0
- workforge/core/parser.py +97 -0
- workforge/models.py +54 -0
- workforge/providers/__init__.py +1 -0
- workforge/providers/base.py +48 -0
- workforge/providers/github.py +606 -0
- workforge/providers/jira.py +509 -0
- workforge/providers/registry.py +17 -0
- workforge/providers/trello.py +469 -0
- workforge-2.4.1.dist-info/METADATA +515 -0
- workforge-2.4.1.dist-info/RECORD +17 -0
- workforge-2.4.1.dist-info/WHEEL +4 -0
- workforge-2.4.1.dist-info/entry_points.txt +2 -0
- workforge-2.4.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
from urllib.parse import urlparse
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
|
|
6
|
+
from workforge.models import CreatedItem, ItemStatus, ProviderCheck, Requirement, TaskStatus
|
|
7
|
+
from workforge.providers.base import PlanningProvider
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class JiraProvider(PlanningProvider):
|
|
11
|
+
name = "jira"
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
config: dict[str, Any],
|
|
16
|
+
env: dict[str, str],
|
|
17
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
18
|
+
):
|
|
19
|
+
self.site_url = config.get("site_url", "").rstrip("/")
|
|
20
|
+
self.project_key = config.get("project_key", "")
|
|
21
|
+
self.issue_type = config.get("issue_type", "")
|
|
22
|
+
self.labels = config.get("labels", {})
|
|
23
|
+
self.versions = config.get("versions", {})
|
|
24
|
+
self.status = config.get("status", {})
|
|
25
|
+
self.email = env.get("JIRA_EMAIL", "")
|
|
26
|
+
self.api_token = env.get("JIRA_API_TOKEN", "")
|
|
27
|
+
self.transport = transport
|
|
28
|
+
|
|
29
|
+
async def check(self) -> ProviderCheck:
|
|
30
|
+
if error := self._configuration_error():
|
|
31
|
+
return ProviderCheck(provider=self.name, ok=False, message=error)
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
async with httpx.AsyncClient(
|
|
35
|
+
base_url=self.site_url,
|
|
36
|
+
auth=(self.email, self.api_token),
|
|
37
|
+
timeout=20,
|
|
38
|
+
transport=self.transport,
|
|
39
|
+
) as client:
|
|
40
|
+
user_response = await client.get("/rest/api/3/myself")
|
|
41
|
+
user_response.raise_for_status()
|
|
42
|
+
project_response = await client.get(f"/rest/api/3/project/{self.project_key}")
|
|
43
|
+
project_response.raise_for_status()
|
|
44
|
+
user = user_response.json()
|
|
45
|
+
project = project_response.json()
|
|
46
|
+
except (httpx.HTTPError, ValueError) as error:
|
|
47
|
+
return ProviderCheck(provider=self.name, ok=False, message=f"Jira request failed: {self._safe_message(error)}")
|
|
48
|
+
|
|
49
|
+
return ProviderCheck(
|
|
50
|
+
provider=self.name,
|
|
51
|
+
ok=True,
|
|
52
|
+
message=f"Jira access verified for {user.get('displayName', self.email)} and project {project.get('key', self.project_key)}.",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def _configuration_error(self) -> str | None:
|
|
56
|
+
missing = [
|
|
57
|
+
name
|
|
58
|
+
for name, value in {
|
|
59
|
+
"JIRA_EMAIL": self.email,
|
|
60
|
+
"JIRA_API_TOKEN": self.api_token,
|
|
61
|
+
"providers.jira.site_url": self.site_url,
|
|
62
|
+
"providers.jira.project_key": self.project_key,
|
|
63
|
+
"providers.jira.issue_type": self.issue_type,
|
|
64
|
+
}.items()
|
|
65
|
+
if not value
|
|
66
|
+
]
|
|
67
|
+
if missing:
|
|
68
|
+
return f"Missing configuration: {', '.join(missing)}"
|
|
69
|
+
|
|
70
|
+
parsed_url = urlparse(self.site_url)
|
|
71
|
+
if parsed_url.scheme != "https" or not parsed_url.netloc:
|
|
72
|
+
return "providers.jira.site_url must be an HTTPS URL."
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
def _safe_message(self, error: object) -> str:
|
|
76
|
+
message = str(error)
|
|
77
|
+
if isinstance(error, httpx.HTTPStatusError):
|
|
78
|
+
try:
|
|
79
|
+
payload = error.response.json()
|
|
80
|
+
details = [*payload.get("errorMessages", []), *payload.get("errors", {}).values()]
|
|
81
|
+
if details:
|
|
82
|
+
message += f": {'; '.join(str(detail) for detail in details)}"
|
|
83
|
+
except (ValueError, AttributeError):
|
|
84
|
+
pass
|
|
85
|
+
for secret in (self.api_token, self.email):
|
|
86
|
+
if secret:
|
|
87
|
+
message = message.replace(secret, "[REDACTED]")
|
|
88
|
+
return message
|
|
89
|
+
|
|
90
|
+
async def create_requirement(self, requirement: Requirement) -> CreatedItem:
|
|
91
|
+
if error := self._configuration_error():
|
|
92
|
+
raise RuntimeError(error)
|
|
93
|
+
|
|
94
|
+
fields: dict[str, Any] = {
|
|
95
|
+
"project": {"key": self.project_key},
|
|
96
|
+
"issuetype": {"name": self.issue_type},
|
|
97
|
+
"summary": requirement.title,
|
|
98
|
+
"description": _adf_document(requirement),
|
|
99
|
+
"labels": self._label_names_for(requirement.labels),
|
|
100
|
+
}
|
|
101
|
+
if requirement.milestone:
|
|
102
|
+
fields["fixVersions"] = [{"id": self._version_id_for(requirement.milestone)}]
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
async with httpx.AsyncClient(
|
|
106
|
+
base_url=self.site_url,
|
|
107
|
+
auth=(self.email, self.api_token),
|
|
108
|
+
timeout=20,
|
|
109
|
+
transport=self.transport,
|
|
110
|
+
) as client:
|
|
111
|
+
response = await client.post("/rest/api/3/issue", json={"fields": fields})
|
|
112
|
+
response.raise_for_status()
|
|
113
|
+
issue = response.json()
|
|
114
|
+
except (httpx.HTTPError, ValueError) as error:
|
|
115
|
+
raise RuntimeError(f"Jira request failed: {self._safe_message(error)}") from error
|
|
116
|
+
|
|
117
|
+
return CreatedItem(
|
|
118
|
+
provider=self.name,
|
|
119
|
+
id=issue["key"],
|
|
120
|
+
url=f"{self.site_url}/browse/{issue['key']}",
|
|
121
|
+
title=requirement.title,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
def _label_names_for(self, logical_names: list[str]) -> list[str]:
|
|
125
|
+
if not isinstance(self.labels, dict):
|
|
126
|
+
return []
|
|
127
|
+
return [label for name in logical_names if isinstance(label := self.labels.get(name), str) and label]
|
|
128
|
+
|
|
129
|
+
def _version_id_for(self, logical_name: str) -> str:
|
|
130
|
+
version_id = self.versions.get(logical_name) if isinstance(self.versions, dict) else None
|
|
131
|
+
if isinstance(version_id, bool) or not isinstance(version_id, (str, int)) or not str(version_id):
|
|
132
|
+
raise ValueError(f"Jira version is not configured: {logical_name}")
|
|
133
|
+
return str(version_id)
|
|
134
|
+
|
|
135
|
+
async def get_item_status(self, item: CreatedItem) -> ItemStatus:
|
|
136
|
+
if error := self._configuration_error():
|
|
137
|
+
raise RuntimeError(error)
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
async with httpx.AsyncClient(
|
|
141
|
+
base_url=self.site_url,
|
|
142
|
+
auth=(self.email, self.api_token),
|
|
143
|
+
timeout=20,
|
|
144
|
+
transport=self.transport,
|
|
145
|
+
) as client:
|
|
146
|
+
issue = await self._get_issue(client, item.id)
|
|
147
|
+
except (httpx.HTTPError, ValueError) as error:
|
|
148
|
+
raise RuntimeError(f"Jira request failed: {self._safe_message(error)}") from error
|
|
149
|
+
|
|
150
|
+
return self._item_status_from_issue(issue, item)
|
|
151
|
+
|
|
152
|
+
async def update_requirement_tasks(self, item: CreatedItem, requirement: Requirement) -> ItemStatus:
|
|
153
|
+
if error := self._configuration_error():
|
|
154
|
+
raise RuntimeError(error)
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
async with httpx.AsyncClient(
|
|
158
|
+
base_url=self.site_url,
|
|
159
|
+
auth=(self.email, self.api_token),
|
|
160
|
+
timeout=20,
|
|
161
|
+
transport=self.transport,
|
|
162
|
+
) as client:
|
|
163
|
+
issue = await self._get_issue(client, item.id)
|
|
164
|
+
description = issue.get("fields", {}).get("description") or _empty_adf_document()
|
|
165
|
+
updated = _sync_adf_tasks(description, requirement)
|
|
166
|
+
if updated != description:
|
|
167
|
+
response = await client.put(f"/rest/api/3/issue/{item.id}", json={"fields": {"description": updated}})
|
|
168
|
+
response.raise_for_status()
|
|
169
|
+
issue["fields"]["description"] = updated
|
|
170
|
+
except (httpx.HTTPError, ValueError) as error:
|
|
171
|
+
raise RuntimeError(f"Jira request failed: {self._safe_message(error)}") from error
|
|
172
|
+
|
|
173
|
+
return self._item_status_from_issue(issue, item)
|
|
174
|
+
|
|
175
|
+
async def complete_task(self, item: CreatedItem, task_ref: str) -> ItemStatus:
|
|
176
|
+
if error := self._configuration_error():
|
|
177
|
+
raise RuntimeError(error)
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
async with httpx.AsyncClient(
|
|
181
|
+
base_url=self.site_url,
|
|
182
|
+
auth=(self.email, self.api_token),
|
|
183
|
+
timeout=20,
|
|
184
|
+
transport=self.transport,
|
|
185
|
+
) as client:
|
|
186
|
+
issue = await self._get_issue(client, item.id)
|
|
187
|
+
description = issue.get("fields", {}).get("description") or _empty_adf_document()
|
|
188
|
+
task = _find_adf_task(description, task_ref)
|
|
189
|
+
if task.get("attrs", {}).get("state") != "DONE":
|
|
190
|
+
task.setdefault("attrs", {})["state"] = "DONE"
|
|
191
|
+
response = await client.put(f"/rest/api/3/issue/{item.id}", json={"fields": {"description": description}})
|
|
192
|
+
response.raise_for_status()
|
|
193
|
+
except (httpx.HTTPError, ValueError) as error:
|
|
194
|
+
raise RuntimeError(f"Jira request failed: {self._safe_message(error)}") from error
|
|
195
|
+
|
|
196
|
+
return self._item_status_from_issue(issue, item)
|
|
197
|
+
|
|
198
|
+
async def _get_issue(self, client: httpx.AsyncClient, issue_id: str) -> dict[str, Any]:
|
|
199
|
+
response = await client.get(
|
|
200
|
+
f"/rest/api/3/issue/{issue_id}",
|
|
201
|
+
params={"fields": "summary,status,description"},
|
|
202
|
+
)
|
|
203
|
+
response.raise_for_status()
|
|
204
|
+
return response.json()
|
|
205
|
+
|
|
206
|
+
def _item_status_from_issue(self, issue: dict[str, Any], item: CreatedItem) -> ItemStatus:
|
|
207
|
+
fields = issue.get("fields", {})
|
|
208
|
+
status_category = fields.get("status", {}).get("statusCategory", {}).get("key")
|
|
209
|
+
return ItemStatus(
|
|
210
|
+
provider=self.name,
|
|
211
|
+
id=issue.get("key", item.id),
|
|
212
|
+
url=f"{self.site_url}/browse/{issue.get('key', item.id)}",
|
|
213
|
+
title=fields.get("summary") or item.title,
|
|
214
|
+
closed=status_category == "done",
|
|
215
|
+
tasks=_task_statuses_from_adf(fields.get("description")),
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
async def comment_item(self, item: CreatedItem, text: str) -> ItemStatus:
|
|
219
|
+
if error := self._configuration_error():
|
|
220
|
+
raise RuntimeError(error)
|
|
221
|
+
if not text.strip():
|
|
222
|
+
raise ValueError("Comment text cannot be empty.")
|
|
223
|
+
|
|
224
|
+
try:
|
|
225
|
+
async with httpx.AsyncClient(
|
|
226
|
+
base_url=self.site_url,
|
|
227
|
+
auth=(self.email, self.api_token),
|
|
228
|
+
timeout=20,
|
|
229
|
+
transport=self.transport,
|
|
230
|
+
) as client:
|
|
231
|
+
response = await client.post(
|
|
232
|
+
f"/rest/api/3/issue/{item.id}/comment",
|
|
233
|
+
json={"body": _paragraph_adf(text)},
|
|
234
|
+
)
|
|
235
|
+
response.raise_for_status()
|
|
236
|
+
except httpx.HTTPError as error:
|
|
237
|
+
raise RuntimeError(f"Jira request failed: {self._safe_message(error)}") from error
|
|
238
|
+
|
|
239
|
+
return await self.get_item_status(item)
|
|
240
|
+
|
|
241
|
+
async def move_item(self, item: CreatedItem, status_ref: str) -> ItemStatus:
|
|
242
|
+
if error := self._configuration_error():
|
|
243
|
+
raise RuntimeError(error)
|
|
244
|
+
|
|
245
|
+
try:
|
|
246
|
+
async with httpx.AsyncClient(
|
|
247
|
+
base_url=self.site_url,
|
|
248
|
+
auth=(self.email, self.api_token),
|
|
249
|
+
timeout=20,
|
|
250
|
+
transport=self.transport,
|
|
251
|
+
) as client:
|
|
252
|
+
response = await client.get(f"/rest/api/3/issue/{item.id}/transitions")
|
|
253
|
+
response.raise_for_status()
|
|
254
|
+
transitions = response.json().get("transitions", [])
|
|
255
|
+
transition = self._resolve_transition(transitions, status_ref)
|
|
256
|
+
response = await client.post(
|
|
257
|
+
f"/rest/api/3/issue/{item.id}/transitions",
|
|
258
|
+
json={"transition": {"id": transition["id"]}},
|
|
259
|
+
)
|
|
260
|
+
response.raise_for_status()
|
|
261
|
+
except httpx.HTTPError as error:
|
|
262
|
+
raise RuntimeError(f"Jira request failed: {self._safe_message(error)}") from error
|
|
263
|
+
|
|
264
|
+
return await self.get_item_status(item)
|
|
265
|
+
|
|
266
|
+
async def claim_item(self, item: CreatedItem, assignee_ref: str = "@me") -> ItemStatus:
|
|
267
|
+
if error := self._configuration_error():
|
|
268
|
+
raise RuntimeError(error)
|
|
269
|
+
|
|
270
|
+
try:
|
|
271
|
+
async with httpx.AsyncClient(
|
|
272
|
+
base_url=self.site_url,
|
|
273
|
+
auth=(self.email, self.api_token),
|
|
274
|
+
timeout=20,
|
|
275
|
+
transport=self.transport,
|
|
276
|
+
) as client:
|
|
277
|
+
account_id = assignee_ref
|
|
278
|
+
if assignee_ref.casefold() == "@me":
|
|
279
|
+
response = await client.get("/rest/api/3/myself")
|
|
280
|
+
response.raise_for_status()
|
|
281
|
+
account_id = response.json()["accountId"]
|
|
282
|
+
response = await client.put(
|
|
283
|
+
f"/rest/api/3/issue/{item.id}/assignee",
|
|
284
|
+
json={"accountId": account_id},
|
|
285
|
+
)
|
|
286
|
+
response.raise_for_status()
|
|
287
|
+
except (httpx.HTTPError, ValueError, KeyError) as error:
|
|
288
|
+
raise RuntimeError(f"Jira request failed: {self._safe_message(error)}") from error
|
|
289
|
+
|
|
290
|
+
return await self.get_item_status(item)
|
|
291
|
+
|
|
292
|
+
def _resolve_transition(self, transitions: list[dict[str, Any]], status_ref: str) -> dict[str, Any]:
|
|
293
|
+
values = self.status.get("values", {}) if isinstance(self.status, dict) else {}
|
|
294
|
+
target = values.get(status_ref, status_ref) if isinstance(values, dict) else status_ref
|
|
295
|
+
matches = [
|
|
296
|
+
transition
|
|
297
|
+
for transition in transitions
|
|
298
|
+
if transition.get("id") == target
|
|
299
|
+
or transition.get("name", "").casefold() == str(target).casefold()
|
|
300
|
+
or transition.get("to", {}).get("name", "").casefold() == str(target).casefold()
|
|
301
|
+
]
|
|
302
|
+
if len(matches) == 1:
|
|
303
|
+
return matches[0]
|
|
304
|
+
if len(matches) > 1:
|
|
305
|
+
raise ValueError(f"Multiple Jira transitions matched: {status_ref}")
|
|
306
|
+
available = ", ".join(
|
|
307
|
+
f"{transition.get('id')} ({transition.get('name')} → {transition.get('to', {}).get('name')})"
|
|
308
|
+
for transition in transitions
|
|
309
|
+
)
|
|
310
|
+
raise ValueError(f"Jira transition not available for '{status_ref}'. Available: {available or 'none'}")
|
|
311
|
+
|
|
312
|
+
async def discover_items(
|
|
313
|
+
self,
|
|
314
|
+
label_ref: str | None = None,
|
|
315
|
+
assignee_ref: str | None = None,
|
|
316
|
+
status_ref: str | None = None,
|
|
317
|
+
) -> list[CreatedItem]:
|
|
318
|
+
if error := self._configuration_error():
|
|
319
|
+
raise RuntimeError(error)
|
|
320
|
+
|
|
321
|
+
jql = self._discovery_jql(label_ref, assignee_ref, status_ref)
|
|
322
|
+
discovered: list[CreatedItem] = []
|
|
323
|
+
next_page_token: str | None = None
|
|
324
|
+
try:
|
|
325
|
+
async with httpx.AsyncClient(
|
|
326
|
+
base_url=self.site_url,
|
|
327
|
+
auth=(self.email, self.api_token),
|
|
328
|
+
timeout=20,
|
|
329
|
+
transport=self.transport,
|
|
330
|
+
) as client:
|
|
331
|
+
while True:
|
|
332
|
+
body: dict[str, Any] = {"jql": jql, "fields": ["summary"], "maxResults": 100}
|
|
333
|
+
if next_page_token:
|
|
334
|
+
body["nextPageToken"] = next_page_token
|
|
335
|
+
response = await client.post("/rest/api/3/search/jql", json=body)
|
|
336
|
+
response.raise_for_status()
|
|
337
|
+
page = response.json()
|
|
338
|
+
discovered.extend(
|
|
339
|
+
CreatedItem(
|
|
340
|
+
provider=self.name,
|
|
341
|
+
id=issue["key"],
|
|
342
|
+
url=f"{self.site_url}/browse/{issue['key']}",
|
|
343
|
+
title=issue.get("fields", {}).get("summary") or issue["key"],
|
|
344
|
+
)
|
|
345
|
+
for issue in page.get("issues", [])
|
|
346
|
+
)
|
|
347
|
+
next_page_token = page.get("nextPageToken")
|
|
348
|
+
if not next_page_token:
|
|
349
|
+
break
|
|
350
|
+
except (httpx.HTTPError, ValueError) as error:
|
|
351
|
+
raise RuntimeError(f"Jira request failed: {self._safe_message(error)}") from error
|
|
352
|
+
|
|
353
|
+
return discovered
|
|
354
|
+
|
|
355
|
+
def _discovery_jql(
|
|
356
|
+
self,
|
|
357
|
+
label_ref: str | None,
|
|
358
|
+
assignee_ref: str | None,
|
|
359
|
+
status_ref: str | None,
|
|
360
|
+
) -> str:
|
|
361
|
+
clauses = [f'project = {_jql_quote(self.project_key)}', 'statusCategory != "Done"']
|
|
362
|
+
if label_ref:
|
|
363
|
+
label = self.labels.get(label_ref, label_ref) if isinstance(self.labels, dict) else label_ref
|
|
364
|
+
clauses.append(f"labels = {_jql_quote(str(label))}")
|
|
365
|
+
if assignee_ref:
|
|
366
|
+
assignee = "currentUser()" if assignee_ref.casefold() == "@me" else _jql_quote(assignee_ref)
|
|
367
|
+
clauses.append(f"assignee = {assignee}")
|
|
368
|
+
if status_ref:
|
|
369
|
+
values = self.status.get("values", {}) if isinstance(self.status, dict) else {}
|
|
370
|
+
status = values.get(status_ref, status_ref) if isinstance(values, dict) else status_ref
|
|
371
|
+
clauses.append(f"status = {_jql_quote(str(status))}")
|
|
372
|
+
return " AND ".join(clauses) + " ORDER BY created ASC"
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _adf_document(requirement: Requirement) -> dict[str, Any]:
|
|
376
|
+
content: list[dict[str, Any]] = []
|
|
377
|
+
if requirement.description:
|
|
378
|
+
content.append({"type": "paragraph", "content": [{"type": "text", "text": requirement.description}]})
|
|
379
|
+
if requirement.tasks:
|
|
380
|
+
content.extend(
|
|
381
|
+
[
|
|
382
|
+
{"type": "heading", "attrs": {"level": 2}, "content": [{"type": "text", "text": "Tasks"}]},
|
|
383
|
+
{
|
|
384
|
+
"type": "taskList",
|
|
385
|
+
"attrs": {"localId": "workforge-tasks"},
|
|
386
|
+
"content": [
|
|
387
|
+
{
|
|
388
|
+
"type": "taskItem",
|
|
389
|
+
"attrs": {
|
|
390
|
+
"localId": f"workforge-task-{index}",
|
|
391
|
+
"state": "DONE" if task.done else "TODO",
|
|
392
|
+
},
|
|
393
|
+
"content": [{"type": "text", "text": task.title}],
|
|
394
|
+
}
|
|
395
|
+
for index, task in enumerate(requirement.tasks, start=1)
|
|
396
|
+
],
|
|
397
|
+
},
|
|
398
|
+
]
|
|
399
|
+
)
|
|
400
|
+
return {"type": "doc", "version": 1, "content": content}
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _empty_adf_document() -> dict[str, Any]:
|
|
404
|
+
return {"type": "doc", "version": 1, "content": []}
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _paragraph_adf(text: str) -> dict[str, Any]:
|
|
408
|
+
return {"type": "doc", "version": 1, "content": [{"type": "paragraph", "content": [{"type": "text", "text": text}]}]}
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _jql_quote(value: str) -> str:
|
|
412
|
+
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
|
|
413
|
+
return f'"{escaped}"'
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _managed_adf_tasks(description: dict[str, Any] | None) -> list[dict[str, Any]]:
|
|
417
|
+
if not isinstance(description, dict):
|
|
418
|
+
return []
|
|
419
|
+
for node in description.get("content", []):
|
|
420
|
+
if node.get("type") == "taskList" and node.get("attrs", {}).get("localId") == "workforge-tasks":
|
|
421
|
+
return [task for task in node.get("content", []) if task.get("type") == "taskItem"]
|
|
422
|
+
return []
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _task_title(task: dict[str, Any]) -> str:
|
|
426
|
+
return "".join(node.get("text", "") for node in task.get("content", []) if node.get("type") == "text")
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _task_statuses_from_adf(description: dict[str, Any] | None) -> list[TaskStatus]:
|
|
430
|
+
return [
|
|
431
|
+
TaskStatus(
|
|
432
|
+
id=task.get("attrs", {}).get("localId"),
|
|
433
|
+
title=_task_title(task),
|
|
434
|
+
done=task.get("attrs", {}).get("state") == "DONE",
|
|
435
|
+
)
|
|
436
|
+
for task in _managed_adf_tasks(description)
|
|
437
|
+
]
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _sync_adf_tasks(description: dict[str, Any], requirement: Requirement) -> dict[str, Any]:
|
|
441
|
+
content = description.get("content", [])
|
|
442
|
+
list_index = next(
|
|
443
|
+
(
|
|
444
|
+
index
|
|
445
|
+
for index, node in enumerate(content)
|
|
446
|
+
if node.get("type") == "taskList" and node.get("attrs", {}).get("localId") == "workforge-tasks"
|
|
447
|
+
),
|
|
448
|
+
None,
|
|
449
|
+
)
|
|
450
|
+
completed: dict[str, list[bool]] = {}
|
|
451
|
+
for task in _managed_adf_tasks(description):
|
|
452
|
+
completed.setdefault(_task_title(task).casefold(), []).append(task.get("attrs", {}).get("state") == "DONE")
|
|
453
|
+
|
|
454
|
+
tasks = []
|
|
455
|
+
for index, task in enumerate(requirement.tasks, start=1):
|
|
456
|
+
states = completed.get(task.title.casefold(), [])
|
|
457
|
+
done = states.pop(0) if states else task.done
|
|
458
|
+
tasks.append(
|
|
459
|
+
{
|
|
460
|
+
"type": "taskItem",
|
|
461
|
+
"attrs": {"localId": f"workforge-task-{index}", "state": "DONE" if done else "TODO"},
|
|
462
|
+
"content": [{"type": "text", "text": task.title}],
|
|
463
|
+
}
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
updated_content = list(content)
|
|
467
|
+
if list_index is not None:
|
|
468
|
+
if tasks:
|
|
469
|
+
updated_content[list_index] = {
|
|
470
|
+
"type": "taskList",
|
|
471
|
+
"attrs": {"localId": "workforge-tasks"},
|
|
472
|
+
"content": tasks,
|
|
473
|
+
}
|
|
474
|
+
else:
|
|
475
|
+
updated_content.pop(list_index)
|
|
476
|
+
if list_index and _is_tasks_heading(updated_content[list_index - 1]):
|
|
477
|
+
updated_content.pop(list_index - 1)
|
|
478
|
+
elif tasks:
|
|
479
|
+
updated_content.extend(
|
|
480
|
+
[
|
|
481
|
+
{"type": "heading", "attrs": {"level": 2}, "content": [{"type": "text", "text": "Tasks"}]},
|
|
482
|
+
{"type": "taskList", "attrs": {"localId": "workforge-tasks"}, "content": tasks},
|
|
483
|
+
]
|
|
484
|
+
)
|
|
485
|
+
return {**description, "content": updated_content}
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _is_tasks_heading(node: dict[str, Any]) -> bool:
|
|
489
|
+
return node.get("type") == "heading" and _task_title(node) == "Tasks"
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _find_adf_task(description: dict[str, Any], task_ref: str) -> dict[str, Any]:
|
|
493
|
+
tasks = _managed_adf_tasks(description)
|
|
494
|
+
exact = [
|
|
495
|
+
task
|
|
496
|
+
for task in tasks
|
|
497
|
+
if task.get("attrs", {}).get("localId") == task_ref or _task_title(task).casefold() == task_ref.casefold()
|
|
498
|
+
]
|
|
499
|
+
if len(exact) == 1:
|
|
500
|
+
return exact[0]
|
|
501
|
+
if len(exact) > 1:
|
|
502
|
+
raise ValueError(f"Multiple tasks matched exactly: {task_ref}")
|
|
503
|
+
|
|
504
|
+
partial = [task for task in tasks if task_ref.casefold() in _task_title(task).casefold()]
|
|
505
|
+
if len(partial) == 1:
|
|
506
|
+
return partial[0]
|
|
507
|
+
if len(partial) > 1:
|
|
508
|
+
raise ValueError(f"Multiple tasks matched '{task_ref}': {', '.join(_task_title(task) for task in partial)}")
|
|
509
|
+
raise ValueError(f"Task not found: {task_ref}")
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from workforge.providers.base import PlanningProvider
|
|
4
|
+
from workforge.providers.github import GitHubProvider
|
|
5
|
+
from workforge.providers.jira import JiraProvider
|
|
6
|
+
from workforge.providers.trello import TrelloProvider
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def build_provider(name: str, config: dict[str, Any], env: dict[str, str] | None = None) -> PlanningProvider:
|
|
10
|
+
if name == "trello":
|
|
11
|
+
return TrelloProvider(config, env or {})
|
|
12
|
+
if name == "github":
|
|
13
|
+
return GitHubProvider(config, env or {})
|
|
14
|
+
if name == "jira":
|
|
15
|
+
return JiraProvider(config, env or {})
|
|
16
|
+
|
|
17
|
+
raise ValueError(f"Unknown provider: {name}")
|