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,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)
|