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.
@@ -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}")