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,469 @@
1
+ from typing import Any
2
+
3
+ import httpx
4
+
5
+ from workforge.models import CreatedItem, ItemStatus, ProviderCheck, Requirement, TaskStatus
6
+ from workforge.providers.base import PlanningProvider
7
+
8
+
9
+ class TrelloProvider(PlanningProvider):
10
+ name = "trello"
11
+
12
+ def __init__(
13
+ self,
14
+ config: dict[str, Any],
15
+ env: dict[str, str],
16
+ transport: httpx.AsyncBaseTransport | None = None,
17
+ ):
18
+ self.config = config
19
+ self.env = env
20
+ self.api_key = env.get("TRELLO_API_KEY", "")
21
+ self.api_token = env.get("TRELLO_API_TOKEN", "")
22
+ self.list_id = config.get("list_id", "")
23
+ self.labels = config.get("labels", {})
24
+ self.lists = config.get("lists", {})
25
+ self.base_url = "https://api.trello.com/1"
26
+ self.transport = transport
27
+
28
+ async def check(self) -> ProviderCheck:
29
+ missing = [
30
+ name
31
+ for name, value in {
32
+ "TRELLO_API_KEY": self.api_key,
33
+ "TRELLO_API_TOKEN": self.api_token,
34
+ "providers.trello.list_id": self.list_id,
35
+ }.items()
36
+ if not value
37
+ ]
38
+
39
+ if missing:
40
+ return ProviderCheck(
41
+ provider=self.name,
42
+ ok=False,
43
+ message=f"Missing configuration: {', '.join(missing)}",
44
+ )
45
+
46
+ return ProviderCheck(provider=self.name, ok=True, message="Trello configuration is present.")
47
+
48
+ async def create_requirement(self, requirement: Requirement) -> CreatedItem:
49
+ check = await self.check()
50
+ if not check.ok:
51
+ raise RuntimeError(check.message)
52
+
53
+ async with httpx.AsyncClient(base_url=self.base_url, timeout=20, transport=self.transport) as client:
54
+ card = await self._create_card(client, requirement)
55
+ if requirement.tasks:
56
+ checklist = await self._create_checklist(client, card["id"])
57
+ for task in requirement.tasks:
58
+ await self._create_check_item(client, checklist["id"], task.title)
59
+
60
+ return CreatedItem(
61
+ provider=self.name,
62
+ id=card["id"],
63
+ url=card.get("shortUrl") or card.get("url"),
64
+ title=requirement.title,
65
+ )
66
+
67
+ async def get_item_status(self, item: CreatedItem) -> ItemStatus:
68
+ check = await self.check()
69
+ if not check.ok:
70
+ raise RuntimeError(check.message)
71
+
72
+ async with httpx.AsyncClient(base_url=self.base_url, timeout=20, transport=self.transport) as client:
73
+ card = await self._get_card(client, item.id)
74
+ checklists = await self._get_card_checklists(client, item.id)
75
+
76
+ tasks = _task_statuses_from_checklists(checklists)
77
+
78
+ return ItemStatus(
79
+ provider=self.name,
80
+ id=card["id"],
81
+ url=card.get("shortUrl") or card.get("url") or item.url,
82
+ title=card.get("name") or item.title,
83
+ closed=bool(card.get("closed")),
84
+ tasks=tasks,
85
+ )
86
+
87
+ async def update_requirement_tasks(self, item: CreatedItem, requirement: Requirement) -> ItemStatus:
88
+ check = await self.check()
89
+ if not check.ok:
90
+ raise RuntimeError(check.message)
91
+
92
+ async with httpx.AsyncClient(base_url=self.base_url, timeout=20, transport=self.transport) as client:
93
+ card = await self._get_card(client, item.id)
94
+ checklists = await self._get_card_checklists(client, item.id)
95
+ managed = [checklist for checklist in checklists if checklist.get("name") == "Tasks"]
96
+ completed: dict[str, list[bool]] = {}
97
+ for task in _task_statuses_from_checklists(managed):
98
+ completed.setdefault(task.title.casefold(), []).append(task.done)
99
+ updated_checklists = [checklist for checklist in checklists if checklist not in managed]
100
+ if requirement.tasks:
101
+ checklist = await self._create_checklist(client, item.id)
102
+ for task in requirement.tasks:
103
+ created = await self._create_check_item(client, checklist["id"], task.title)
104
+ states = completed.get(task.title.casefold(), [])
105
+ if states.pop(0) if states else task.done:
106
+ await self._update_check_item_state(client, item.id, created["id"], "complete")
107
+ for checklist in managed:
108
+ await self._delete_checklist(client, checklist["id"])
109
+ if requirement.tasks:
110
+ updated_checklists = await self._get_card_checklists(client, item.id)
111
+
112
+ return ItemStatus(
113
+ provider=self.name,
114
+ id=card["id"],
115
+ url=card.get("shortUrl") or card.get("url") or item.url,
116
+ title=card.get("name") or item.title,
117
+ closed=bool(card.get("closed")),
118
+ tasks=_task_statuses_from_checklists(updated_checklists),
119
+ )
120
+
121
+ async def complete_task(self, item: CreatedItem, task_ref: str) -> ItemStatus:
122
+ check = await self.check()
123
+ if not check.ok:
124
+ raise RuntimeError(check.message)
125
+
126
+ async with httpx.AsyncClient(base_url=self.base_url, timeout=20, transport=self.transport) as client:
127
+ card = await self._get_card(client, item.id)
128
+ checklists = await self._get_card_checklists(client, item.id)
129
+ task = _find_task(checklists, task_ref)
130
+ await self._update_check_item_state(client, item.id, task["id"], "complete")
131
+ updated_checklists = await self._get_card_checklists(client, item.id)
132
+
133
+ return ItemStatus(
134
+ provider=self.name,
135
+ id=card["id"],
136
+ url=card.get("shortUrl") or card.get("url") or item.url,
137
+ title=card.get("name") or item.title,
138
+ closed=bool(card.get("closed")),
139
+ tasks=_task_statuses_from_checklists(updated_checklists),
140
+ )
141
+
142
+ async def comment_item(self, item: CreatedItem, text: str) -> ItemStatus:
143
+ check = await self.check()
144
+ if not check.ok:
145
+ raise RuntimeError(check.message)
146
+
147
+ async with httpx.AsyncClient(base_url=self.base_url, timeout=20, transport=self.transport) as client:
148
+ await self._comment_card(client, item.id, text)
149
+
150
+ return await self.get_item_status(item)
151
+
152
+ async def move_item(self, item: CreatedItem, status_ref: str) -> ItemStatus:
153
+ check = await self.check()
154
+ if not check.ok:
155
+ raise RuntimeError(check.message)
156
+
157
+ list_id = self._configured_list_id(status_ref) or status_ref
158
+
159
+ async with httpx.AsyncClient(base_url=self.base_url, timeout=20, transport=self.transport) as client:
160
+ await self._move_card(client, item.id, list_id)
161
+
162
+ return await self.get_item_status(item)
163
+
164
+ async def claim_item(self, item: CreatedItem, assignee_ref: str = "@me") -> ItemStatus:
165
+ check = await self.check()
166
+ if not check.ok:
167
+ raise RuntimeError(check.message)
168
+
169
+ async with httpx.AsyncClient(base_url=self.base_url, timeout=20, transport=self.transport) as client:
170
+ board_id = await self._get_board_id_for_list(client, self.list_id)
171
+ member_id = await self._resolve_member_id(client, board_id, assignee_ref)
172
+ response = await client.post(
173
+ f"/cards/{item.id}/idMembers",
174
+ params={**self._auth_params(), "value": member_id},
175
+ )
176
+ response.raise_for_status()
177
+
178
+ return await self.get_item_status(item)
179
+
180
+ async def discover_items(
181
+ self,
182
+ label_ref: str | None = None,
183
+ assignee_ref: str | None = None,
184
+ status_ref: str | None = None,
185
+ ) -> list[CreatedItem]:
186
+ check = await self.check()
187
+ if not check.ok:
188
+ raise RuntimeError(check.message)
189
+
190
+ async with httpx.AsyncClient(base_url=self.base_url, timeout=20, transport=self.transport) as client:
191
+ board_id = await self._get_board_id_for_list(client, self.list_id)
192
+ label_id = await self._resolve_label_id(client, board_id, label_ref) if label_ref else None
193
+ member_id = await self._resolve_member_id(client, board_id, assignee_ref) if assignee_ref else None
194
+ list_id = self._configured_list_id(status_ref) or status_ref if status_ref else None
195
+ cards = await self._get_board_cards(client, board_id)
196
+
197
+ discovered_cards = [
198
+ card
199
+ for card in cards
200
+ if not card.get("closed")
201
+ and _card_matches_label(card, label_id)
202
+ and _card_matches_member(card, member_id)
203
+ and (list_id is None or card.get("idList") == list_id)
204
+ ]
205
+
206
+ return [
207
+ CreatedItem(
208
+ provider=self.name,
209
+ id=card["id"],
210
+ url=card.get("shortUrl") or card.get("url"),
211
+ title=card["name"],
212
+ )
213
+ for card in discovered_cards
214
+ ]
215
+
216
+ def _auth_params(self) -> dict[str, str]:
217
+ return {"key": self.api_key, "token": self.api_token}
218
+
219
+ async def _create_card(self, client: httpx.AsyncClient, requirement: Requirement) -> dict[str, Any]:
220
+ description = _build_description(requirement)
221
+ label_ids = self._label_ids_for(requirement.labels)
222
+ response = await client.post(
223
+ "/cards",
224
+ params=self._auth_params(),
225
+ json={
226
+ "idList": self.list_id,
227
+ "name": requirement.title,
228
+ "desc": description,
229
+ "idLabels": label_ids,
230
+ },
231
+ )
232
+ response.raise_for_status()
233
+ return response.json()
234
+
235
+ def _label_ids_for(self, label_names: list[str]) -> list[str]:
236
+ if not isinstance(self.labels, dict):
237
+ return []
238
+
239
+ return [
240
+ label_id
241
+ for label_name in label_names
242
+ if isinstance(label_id := self.labels.get(label_name), str) and label_id
243
+ ]
244
+
245
+ def _configured_label_id(self, label_ref: str) -> str | None:
246
+ if not isinstance(self.labels, dict):
247
+ return None
248
+
249
+ value = self.labels.get(label_ref)
250
+ if isinstance(value, str) and value:
251
+ return value
252
+
253
+ return None
254
+
255
+ def _configured_list_id(self, list_ref: str) -> str | None:
256
+ if not isinstance(self.lists, dict):
257
+ return None
258
+
259
+ value = self.lists.get(list_ref)
260
+ if isinstance(value, str) and value:
261
+ return value
262
+
263
+ return None
264
+
265
+ async def _create_checklist(self, client: httpx.AsyncClient, card_id: str) -> dict[str, Any]:
266
+ response = await client.post(
267
+ f"/cards/{card_id}/checklists",
268
+ params=self._auth_params(),
269
+ json={"name": "Tasks"},
270
+ )
271
+ response.raise_for_status()
272
+ return response.json()
273
+
274
+ async def _create_check_item(self, client: httpx.AsyncClient, checklist_id: str, name: str) -> dict[str, Any]:
275
+ response = await client.post(
276
+ f"/checklists/{checklist_id}/checkItems",
277
+ params=self._auth_params(),
278
+ json={"name": name},
279
+ )
280
+ response.raise_for_status()
281
+ return response.json()
282
+
283
+ async def _delete_checklist(self, client: httpx.AsyncClient, checklist_id: str) -> None:
284
+ response = await client.delete(f"/checklists/{checklist_id}", params=self._auth_params())
285
+ response.raise_for_status()
286
+
287
+ async def _get_card(self, client: httpx.AsyncClient, card_id: str) -> dict[str, Any]:
288
+ response = await client.get(
289
+ f"/cards/{card_id}",
290
+ params={**self._auth_params(), "fields": "id,name,closed,shortUrl,url"},
291
+ )
292
+ response.raise_for_status()
293
+ return response.json()
294
+
295
+ async def _get_card_checklists(self, client: httpx.AsyncClient, card_id: str) -> list[dict[str, Any]]:
296
+ response = await client.get(
297
+ f"/cards/{card_id}/checklists",
298
+ params={**self._auth_params(), "checkItems": "all"},
299
+ )
300
+ response.raise_for_status()
301
+ return response.json()
302
+
303
+ async def _get_board_id_for_list(self, client: httpx.AsyncClient, list_id: str) -> str:
304
+ response = await client.get(
305
+ f"/lists/{list_id}",
306
+ params={**self._auth_params(), "fields": "idBoard"},
307
+ )
308
+ response.raise_for_status()
309
+ return response.json()["idBoard"]
310
+
311
+ async def _get_board_cards(self, client: httpx.AsyncClient, board_id: str) -> list[dict[str, Any]]:
312
+ response = await client.get(
313
+ f"/boards/{board_id}/cards",
314
+ params={**self._auth_params(), "fields": "id,name,closed,shortUrl,url,idLabels,labels,idMembers,idList"},
315
+ )
316
+ response.raise_for_status()
317
+ return response.json()
318
+
319
+ async def _resolve_member_id(self, client: httpx.AsyncClient, board_id: str, member_ref: str) -> str:
320
+ if member_ref.casefold() == "@me":
321
+ response = await client.get(
322
+ "/members/me",
323
+ params={**self._auth_params(), "fields": "id"},
324
+ )
325
+ response.raise_for_status()
326
+ return response.json()["id"]
327
+
328
+ response = await client.get(
329
+ f"/boards/{board_id}/members",
330
+ params={**self._auth_params(), "fields": "id,username,fullName"},
331
+ )
332
+ response.raise_for_status()
333
+ expected = member_ref.removeprefix("@").casefold()
334
+ matches = [
335
+ member
336
+ for member in response.json()
337
+ if member.get("id") == member_ref
338
+ or member.get("username", "").casefold() == expected
339
+ or member.get("fullName", "").casefold() == expected
340
+ ]
341
+ if len(matches) == 1:
342
+ return matches[0]["id"]
343
+ if len(matches) > 1:
344
+ raise ValueError(f"Multiple Trello members matched: {member_ref}")
345
+ raise ValueError(f"Trello member not found: {member_ref}")
346
+
347
+ async def _get_board_labels(self, client: httpx.AsyncClient, board_id: str) -> list[dict[str, Any]]:
348
+ response = await client.get(
349
+ f"/boards/{board_id}/labels",
350
+ params={**self._auth_params(), "fields": "id,name,color"},
351
+ )
352
+ response.raise_for_status()
353
+ return response.json()
354
+
355
+ async def _resolve_label_id(self, client: httpx.AsyncClient, board_id: str, label_ref: str) -> str:
356
+ configured_label_id = self._configured_label_id(label_ref)
357
+ if configured_label_id:
358
+ return configured_label_id
359
+
360
+ labels = await self._get_board_labels(client, board_id)
361
+ exact_matches = [
362
+ label
363
+ for label in labels
364
+ if label.get("id") == label_ref or label.get("name", "").casefold() == label_ref.casefold()
365
+ ]
366
+
367
+ if len(exact_matches) == 1:
368
+ return exact_matches[0]["id"]
369
+ if len(exact_matches) > 1:
370
+ raise ValueError(f"Multiple Trello labels matched: {label_ref}")
371
+
372
+ raise ValueError(f"Trello label not found: {label_ref}")
373
+
374
+ async def _update_check_item_state(
375
+ self,
376
+ client: httpx.AsyncClient,
377
+ card_id: str,
378
+ check_item_id: str,
379
+ state: str,
380
+ ) -> dict[str, Any]:
381
+ response = await client.put(
382
+ f"/cards/{card_id}/checkItem/{check_item_id}",
383
+ params=self._auth_params(),
384
+ json={"state": state},
385
+ )
386
+ response.raise_for_status()
387
+ return response.json()
388
+
389
+ async def _comment_card(self, client: httpx.AsyncClient, card_id: str, text: str) -> dict[str, Any]:
390
+ response = await client.post(
391
+ f"/cards/{card_id}/actions/comments",
392
+ params=self._auth_params(),
393
+ json={"text": text},
394
+ )
395
+ response.raise_for_status()
396
+ return response.json()
397
+
398
+ async def _move_card(self, client: httpx.AsyncClient, card_id: str, list_id: str) -> dict[str, Any]:
399
+ response = await client.put(
400
+ f"/cards/{card_id}",
401
+ params=self._auth_params(),
402
+ json={"idList": list_id},
403
+ )
404
+ response.raise_for_status()
405
+ return response.json()
406
+
407
+
408
+ def _task_statuses_from_checklists(checklists: list[dict[str, Any]]) -> list[TaskStatus]:
409
+ return [
410
+ TaskStatus(
411
+ id=check_item.get("id"),
412
+ title=check_item["name"],
413
+ done=check_item.get("state") == "complete",
414
+ )
415
+ for checklist in checklists
416
+ for check_item in checklist.get("checkItems", [])
417
+ ]
418
+
419
+
420
+ def _card_matches_label(card: dict[str, Any], label_id: str | None) -> bool:
421
+ if label_id is None:
422
+ return True
423
+
424
+ if label_id in card.get("idLabels", []):
425
+ return True
426
+
427
+ return any(label.get("id") == label_id for label in card.get("labels", []))
428
+
429
+
430
+ def _card_matches_member(card: dict[str, Any], member_id: str | None) -> bool:
431
+ return member_id is None or member_id in card.get("idMembers", [])
432
+
433
+
434
+ def _find_task(checklists: list[dict[str, Any]], task_ref: str) -> dict[str, Any]:
435
+ tasks = [
436
+ check_item
437
+ for checklist in checklists
438
+ for check_item in checklist.get("checkItems", [])
439
+ ]
440
+
441
+ exact_matches = [
442
+ task
443
+ for task in tasks
444
+ if task.get("id") == task_ref or task.get("name", "").casefold() == task_ref.casefold()
445
+ ]
446
+ if len(exact_matches) == 1:
447
+ return exact_matches[0]
448
+ if len(exact_matches) > 1:
449
+ raise ValueError(f"Multiple tasks matched exactly: {task_ref}")
450
+
451
+ partial_matches = [
452
+ task
453
+ for task in tasks
454
+ if task_ref.casefold() in task.get("name", "").casefold()
455
+ ]
456
+ if len(partial_matches) == 1:
457
+ return partial_matches[0]
458
+ if len(partial_matches) > 1:
459
+ titles = ", ".join(task.get("name", "") for task in partial_matches)
460
+ raise ValueError(f"Multiple tasks matched '{task_ref}': {titles}")
461
+
462
+ raise ValueError(f"Task not found: {task_ref}")
463
+
464
+
465
+ def _build_description(requirement: Requirement) -> str:
466
+ parts = [
467
+ requirement.description,
468
+ ]
469
+ return "\n".join(part for part in parts if part)