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,606 @@
1
+ import re
2
+ from typing import Any
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
+ _CHECK_QUERY = """
11
+ query($owner: String!, $repository: String!, $project: Int!) {
12
+ viewer { login }
13
+ user(login: $owner) {
14
+ repository(name: $repository) { nameWithOwner }
15
+ projectV2(number: $project) { title }
16
+ }
17
+ }
18
+ """
19
+
20
+ _PROJECT_QUERY = """
21
+ query($owner: String!, $project: Int!) {
22
+ user(login: $owner) { projectV2(number: $project) { id } }
23
+ }
24
+ """
25
+
26
+ _ADD_PROJECT_ITEM_MUTATION = """
27
+ mutation($project: ID!, $content: ID!) {
28
+ addProjectV2ItemById(input: {projectId: $project, contentId: $content}) { item { id } }
29
+ }
30
+ """
31
+
32
+ _STATUS_CONTEXT_QUERY = """
33
+ query($owner: String!, $repository: String!, $project: Int!, $issue: Int!) {
34
+ user(login: $owner) {
35
+ projectV2(number: $project) {
36
+ id
37
+ fields(first: 50) {
38
+ nodes {
39
+ ... on ProjectV2SingleSelectField { id name options { id name } }
40
+ }
41
+ }
42
+ }
43
+ }
44
+ repository(owner: $owner, name: $repository) {
45
+ issue(number: $issue) {
46
+ projectItems(first: 20) { nodes { id project { id } } }
47
+ }
48
+ }
49
+ }
50
+ """
51
+
52
+ _UPDATE_STATUS_MUTATION = """
53
+ mutation($project: ID!, $item: ID!, $field: ID!, $option: String!) {
54
+ updateProjectV2ItemFieldValue(
55
+ input: {
56
+ projectId: $project
57
+ itemId: $item
58
+ fieldId: $field
59
+ value: {singleSelectOptionId: $option}
60
+ }
61
+ ) { projectV2Item { id } }
62
+ }
63
+ """
64
+
65
+ _PROJECT_ITEMS_QUERY = """
66
+ query($owner: String!, $project: Int!, $cursor: String) {
67
+ viewer { login }
68
+ user(login: $owner) {
69
+ projectV2(number: $project) {
70
+ items(first: 100, after: $cursor) {
71
+ nodes {
72
+ fieldValues(first: 20) {
73
+ nodes {
74
+ ... on ProjectV2ItemFieldSingleSelectValue {
75
+ name
76
+ field { ... on ProjectV2FieldCommon { name } }
77
+ }
78
+ }
79
+ }
80
+ content {
81
+ ... on Issue {
82
+ number
83
+ title
84
+ url
85
+ state
86
+ repository { nameWithOwner }
87
+ labels(first: 100) { nodes { id name } }
88
+ assignees(first: 100) { nodes { id login } }
89
+ }
90
+ }
91
+ }
92
+ pageInfo { hasNextPage endCursor }
93
+ }
94
+ }
95
+ }
96
+ }
97
+ """
98
+
99
+ _TASK_PATTERN = re.compile(r"^- \[([ xX])\] (.+)$")
100
+
101
+
102
+ class GitHubProvider(PlanningProvider):
103
+ name = "github"
104
+
105
+ def __init__(
106
+ self,
107
+ config: dict[str, Any],
108
+ env: dict[str, str],
109
+ transport: httpx.AsyncBaseTransport | None = None,
110
+ ):
111
+ self.token = env.get("GITHUB_TOKEN", "")
112
+ self.owner = config.get("owner", "")
113
+ self.repository = config.get("repository", "")
114
+ self.project_number = config.get("project_number")
115
+ self.labels = config.get("labels", {})
116
+ self.milestones = config.get("milestones", {})
117
+ self.status = config.get("status", {})
118
+ self.transport = transport
119
+
120
+ async def check(self) -> ProviderCheck:
121
+ if error := self._configuration_error():
122
+ return ProviderCheck(provider=self.name, ok=False, message=error)
123
+
124
+ try:
125
+ async with httpx.AsyncClient(timeout=20, transport=self.transport) as client:
126
+ response = await client.post(
127
+ "https://api.github.com/graphql",
128
+ headers={"Authorization": f"Bearer {self.token}"},
129
+ json={
130
+ "query": _CHECK_QUERY,
131
+ "variables": {
132
+ "owner": self.owner,
133
+ "repository": self.repository,
134
+ "project": self.project_number,
135
+ },
136
+ },
137
+ )
138
+ response.raise_for_status()
139
+ payload = response.json()
140
+ except (httpx.HTTPError, ValueError) as error:
141
+ return ProviderCheck(provider=self.name, ok=False, message=f"GitHub request failed: {self._safe_message(error)}")
142
+
143
+ if errors := payload.get("errors"):
144
+ return ProviderCheck(provider=self.name, ok=False, message=self._safe_message(errors[0].get("message", "GitHub GraphQL error.")))
145
+
146
+ owner = payload.get("data", {}).get("user")
147
+ if not owner:
148
+ return ProviderCheck(provider=self.name, ok=False, message=f"GitHub user not found or inaccessible: {self.owner}")
149
+ if not owner.get("repository"):
150
+ return ProviderCheck(provider=self.name, ok=False, message=f"GitHub repository not found or inaccessible: {self.owner}/{self.repository}")
151
+ if not owner.get("projectV2"):
152
+ return ProviderCheck(provider=self.name, ok=False, message=f"GitHub Project v2 not found or inaccessible: {self.project_number}")
153
+
154
+ return ProviderCheck(
155
+ provider=self.name,
156
+ ok=True,
157
+ message=f"GitHub access verified for {owner['repository']['nameWithOwner']} and project {owner['projectV2']['title']}.",
158
+ )
159
+
160
+ async def create_requirement(self, requirement: Requirement) -> CreatedItem:
161
+ self._require_configuration()
162
+
163
+ async with httpx.AsyncClient(timeout=20, transport=self.transport) as client:
164
+ project_id = await self._get_project_id(client)
165
+ payload: dict[str, Any] = {
166
+ "title": requirement.title,
167
+ "body": _build_issue_body(requirement),
168
+ "labels": self._label_names_for(requirement.labels),
169
+ }
170
+ if requirement.milestone:
171
+ payload["milestone"] = self._milestone_number_for(requirement.milestone)
172
+
173
+ response = await client.post(
174
+ f"https://api.github.com/repos/{self.owner}/{self.repository}/issues",
175
+ headers=self._rest_headers(),
176
+ json=payload,
177
+ )
178
+ response.raise_for_status()
179
+ issue = response.json()
180
+ try:
181
+ await self._add_issue_to_project(client, project_id, issue["node_id"])
182
+ except (httpx.HTTPError, RuntimeError, ValueError) as error:
183
+ raise RuntimeError(
184
+ f"Issue created at {issue.get('html_url', '')}, but adding it to GitHub Project v2 failed: {error}"
185
+ ) from error
186
+
187
+ return CreatedItem(
188
+ provider=self.name,
189
+ id=str(issue["number"]),
190
+ url=issue.get("html_url"),
191
+ title=issue.get("title") or requirement.title,
192
+ )
193
+
194
+ def _missing_configuration(self) -> list[str]:
195
+ return [
196
+ name
197
+ for name, value in {
198
+ "GITHUB_TOKEN": self.token,
199
+ "providers.github.owner": self.owner,
200
+ "providers.github.repository": self.repository,
201
+ "providers.github.project_number": self.project_number,
202
+ }.items()
203
+ if value in (None, "")
204
+ ]
205
+
206
+ def _configuration_error(self) -> str | None:
207
+ if missing := self._missing_configuration():
208
+ return f"Missing configuration: {', '.join(missing)}"
209
+ if isinstance(self.project_number, bool) or not isinstance(self.project_number, int) or self.project_number < 1:
210
+ return "providers.github.project_number must be a positive integer."
211
+ return None
212
+
213
+ def _require_configuration(self) -> None:
214
+ if error := self._configuration_error():
215
+ raise RuntimeError(error)
216
+
217
+ def _safe_message(self, error: object) -> str:
218
+ message = str(error)
219
+ return message.replace(self.token, "[REDACTED]") if self.token else message
220
+
221
+ def _label_names_for(self, logical_names: list[str]) -> list[str]:
222
+ if not isinstance(self.labels, dict):
223
+ return []
224
+ return [label for name in logical_names if isinstance(label := self.labels.get(name), str) and label]
225
+
226
+ def _milestone_number_for(self, logical_name: str) -> int:
227
+ number = self.milestones.get(logical_name) if isinstance(self.milestones, dict) else None
228
+ if isinstance(number, bool) or not isinstance(number, int) or number < 1:
229
+ raise ValueError(f"GitHub milestone is not configured: {logical_name}")
230
+ return number
231
+
232
+ async def _get_project_id(self, client: httpx.AsyncClient) -> str:
233
+ payload = await self._graphql(
234
+ client,
235
+ _PROJECT_QUERY,
236
+ {"owner": self.owner, "project": self.project_number},
237
+ )
238
+ owner = payload.get("data", {}).get("user") or {}
239
+ project = owner.get("projectV2")
240
+ if not project:
241
+ raise RuntimeError(f"GitHub Project v2 not found or inaccessible: {self.project_number}")
242
+ return project["id"]
243
+
244
+ async def _add_issue_to_project(self, client: httpx.AsyncClient, project_id: str, issue_id: str) -> None:
245
+ await self._graphql(
246
+ client,
247
+ _ADD_PROJECT_ITEM_MUTATION,
248
+ {"project": project_id, "content": issue_id},
249
+ )
250
+
251
+ async def _graphql(self, client: httpx.AsyncClient, query: str, variables: dict[str, Any]) -> dict[str, Any]:
252
+ response = await client.post(
253
+ "https://api.github.com/graphql",
254
+ headers={"Authorization": f"Bearer {self.token}"},
255
+ json={"query": query, "variables": variables},
256
+ )
257
+ response.raise_for_status()
258
+ payload = response.json()
259
+ if errors := payload.get("errors"):
260
+ raise RuntimeError(self._safe_message(errors[0].get("message", "GitHub GraphQL error.")))
261
+ return payload
262
+
263
+ async def get_item_status(self, item: CreatedItem) -> ItemStatus:
264
+ self._require_configuration()
265
+
266
+ async with httpx.AsyncClient(timeout=20, transport=self.transport) as client:
267
+ issue = await self._get_issue(client, item.id)
268
+
269
+ return self._item_status_from_issue(issue, item)
270
+
271
+ async def update_requirement_tasks(self, item: CreatedItem, requirement: Requirement) -> ItemStatus:
272
+ self._require_configuration()
273
+
274
+ async with httpx.AsyncClient(timeout=20, transport=self.transport) as client:
275
+ issue = await self._get_issue(client, item.id)
276
+ body = issue.get("body") or ""
277
+ updated_body = _sync_tasks_in_issue_body(body, requirement)
278
+ if updated_body != body:
279
+ issue = await self._update_issue_body(client, item.id, updated_body)
280
+
281
+ return self._item_status_from_issue(issue, item)
282
+
283
+ async def complete_task(self, item: CreatedItem, task_ref: str) -> ItemStatus:
284
+ self._require_configuration()
285
+
286
+ async with httpx.AsyncClient(timeout=20, transport=self.transport) as client:
287
+ issue = await self._get_issue(client, item.id)
288
+ body = issue.get("body") or ""
289
+ updated_body = _complete_task_in_issue_body(body, task_ref)
290
+ if updated_body != body:
291
+ issue = await self._update_issue_body(client, item.id, updated_body)
292
+
293
+ return self._item_status_from_issue(issue, item)
294
+
295
+ async def comment_item(self, item: CreatedItem, text: str) -> ItemStatus:
296
+ self._require_configuration()
297
+ if not text.strip():
298
+ raise ValueError("Comment text cannot be empty.")
299
+
300
+ async with httpx.AsyncClient(timeout=20, transport=self.transport) as client:
301
+ await self._comment_issue(client, item.id, text)
302
+
303
+ return await self.get_item_status(item)
304
+
305
+ async def move_item(self, item: CreatedItem, status_ref: str) -> ItemStatus:
306
+ self._require_configuration()
307
+ if not isinstance(self.status, dict) or not self.status.get("field"):
308
+ raise RuntimeError("Missing configuration: providers.github.status.field")
309
+
310
+ async with httpx.AsyncClient(timeout=20, transport=self.transport) as client:
311
+ context = await self._get_status_context(client, item, status_ref)
312
+ await self._graphql(
313
+ client,
314
+ _UPDATE_STATUS_MUTATION,
315
+ {
316
+ "project": context["project_id"],
317
+ "item": context["item_id"],
318
+ "field": context["field_id"],
319
+ "option": context["option_id"],
320
+ },
321
+ )
322
+
323
+ return await self.get_item_status(item)
324
+
325
+ async def claim_item(self, item: CreatedItem, assignee_ref: str = "@me") -> ItemStatus:
326
+ self._require_configuration()
327
+
328
+ async with httpx.AsyncClient(timeout=20, transport=self.transport) as client:
329
+ assignee = assignee_ref.removeprefix("@")
330
+ if assignee_ref.casefold() == "@me":
331
+ response = await client.get("https://api.github.com/user", headers=self._rest_headers())
332
+ response.raise_for_status()
333
+ assignee = response.json()["login"]
334
+ response = await client.post(
335
+ f"https://api.github.com/repos/{self.owner}/{self.repository}/issues/{item.id}/assignees",
336
+ headers=self._rest_headers(),
337
+ json={"assignees": [assignee]},
338
+ )
339
+ response.raise_for_status()
340
+
341
+ return await self.get_item_status(item)
342
+
343
+ async def discover_items(
344
+ self,
345
+ label_ref: str | None = None,
346
+ assignee_ref: str | None = None,
347
+ status_ref: str | None = None,
348
+ ) -> list[CreatedItem]:
349
+ self._require_configuration()
350
+ if status_ref and (not isinstance(self.status, dict) or not self.status.get("field")):
351
+ raise RuntimeError("Missing configuration: providers.github.status.field")
352
+
353
+ discovered: list[CreatedItem] = []
354
+ cursor: str | None = None
355
+ async with httpx.AsyncClient(timeout=20, transport=self.transport) as client:
356
+ while True:
357
+ payload = await self._graphql(
358
+ client,
359
+ _PROJECT_ITEMS_QUERY,
360
+ {"owner": self.owner, "project": self.project_number, "cursor": cursor},
361
+ )
362
+ project = (payload.get("data", {}).get("user") or {}).get("projectV2") or {}
363
+ viewer_login = payload.get("data", {}).get("viewer", {}).get("login", "")
364
+ items = project.get("items")
365
+ if not items:
366
+ raise ValueError(f"GitHub Project v2 not found: {self.project_number}")
367
+
368
+ for node in items.get("nodes", []):
369
+ node = node or {}
370
+ issue = node.get("content") or {}
371
+ if self._is_discoverable_issue(node, label_ref, assignee_ref, status_ref, viewer_login):
372
+ discovered.append(
373
+ CreatedItem(
374
+ provider=self.name,
375
+ id=str(issue["number"]),
376
+ url=issue.get("url"),
377
+ title=issue["title"],
378
+ )
379
+ )
380
+
381
+ page_info = items.get("pageInfo", {})
382
+ if not page_info.get("hasNextPage"):
383
+ break
384
+ cursor = page_info.get("endCursor")
385
+
386
+ return discovered
387
+
388
+ def _is_discoverable_issue(
389
+ self,
390
+ item: dict[str, Any],
391
+ label_ref: str | None,
392
+ assignee_ref: str | None,
393
+ status_ref: str | None,
394
+ viewer_login: str,
395
+ ) -> bool:
396
+ issue = item.get("content") or {}
397
+ if issue.get("state") != "OPEN":
398
+ return False
399
+ repository = issue.get("repository", {}).get("nameWithOwner", "")
400
+ if repository.casefold() != f"{self.owner}/{self.repository}".casefold():
401
+ return False
402
+ if label_ref:
403
+ configured_name = self.labels.get(label_ref) if isinstance(self.labels, dict) else None
404
+ expected = configured_name or label_ref
405
+ if not any(
406
+ label.get("id") == expected or label.get("name", "").casefold() == expected.casefold()
407
+ for label in issue.get("labels", {}).get("nodes", [])
408
+ ):
409
+ return False
410
+ if assignee_ref:
411
+ expected = viewer_login if assignee_ref.casefold() == "@me" else assignee_ref.removeprefix("@")
412
+ if not any(
413
+ assignee.get("id") == expected or assignee.get("login", "").casefold() == expected.casefold()
414
+ for assignee in issue.get("assignees", {}).get("nodes", [])
415
+ ):
416
+ return False
417
+ if status_ref:
418
+ field_name = str(self.status["field"])
419
+ values = self.status.get("values", {})
420
+ expected = values.get(status_ref, status_ref) if isinstance(values, dict) else status_ref
421
+ if not any(
422
+ value.get("name", "").casefold() == expected.casefold()
423
+ and value.get("field", {}).get("name", "").casefold() == field_name.casefold()
424
+ for value in item.get("fieldValues", {}).get("nodes", [])
425
+ if value
426
+ ):
427
+ return False
428
+ return True
429
+
430
+ async def _get_issue(self, client: httpx.AsyncClient, issue_number: str) -> dict[str, Any]:
431
+ response = await client.get(
432
+ f"https://api.github.com/repos/{self.owner}/{self.repository}/issues/{issue_number}",
433
+ headers=self._rest_headers(),
434
+ )
435
+ response.raise_for_status()
436
+ return response.json()
437
+
438
+ async def _get_status_context(
439
+ self,
440
+ client: httpx.AsyncClient,
441
+ item: CreatedItem,
442
+ status_ref: str,
443
+ ) -> dict[str, str]:
444
+ payload = await self._graphql(
445
+ client,
446
+ _STATUS_CONTEXT_QUERY,
447
+ {
448
+ "owner": self.owner,
449
+ "repository": self.repository,
450
+ "project": self.project_number,
451
+ "issue": int(item.id),
452
+ },
453
+ )
454
+ project = (payload.get("data", {}).get("user") or {}).get("projectV2") or {}
455
+ project_id = project.get("id")
456
+ if not project_id:
457
+ raise ValueError(f"GitHub Project v2 not found: {self.project_number}")
458
+
459
+ field_name = str(self.status["field"])
460
+ fields = [field for field in project.get("fields", {}).get("nodes", []) if field]
461
+ field = next((field for field in fields if field.get("name", "").casefold() == field_name.casefold()), None)
462
+ if not field:
463
+ raise ValueError(f"GitHub Project status field not found: {field_name}")
464
+
465
+ values = self.status.get("values", {})
466
+ option_name = values.get(status_ref, status_ref) if isinstance(values, dict) else status_ref
467
+ option = next(
468
+ (option for option in field.get("options", []) if option.get("name", "").casefold() == option_name.casefold()),
469
+ None,
470
+ )
471
+ if not option:
472
+ raise ValueError(f"GitHub Project status option not found: {option_name}")
473
+
474
+ issue = (payload.get("data", {}).get("repository") or {}).get("issue") or {}
475
+ project_items = issue.get("projectItems", {}).get("nodes", [])
476
+ project_item = next(
477
+ (project_item for project_item in project_items if project_item.get("project", {}).get("id") == project_id),
478
+ None,
479
+ )
480
+ if not project_item:
481
+ raise ValueError(f"Issue is not attached to GitHub Project v2: {item.id}")
482
+
483
+ return {
484
+ "project_id": project_id,
485
+ "item_id": project_item["id"],
486
+ "field_id": field["id"],
487
+ "option_id": option["id"],
488
+ }
489
+
490
+ async def _update_issue_body(
491
+ self,
492
+ client: httpx.AsyncClient,
493
+ issue_number: str,
494
+ body: str,
495
+ ) -> dict[str, Any]:
496
+ response = await client.patch(
497
+ f"https://api.github.com/repos/{self.owner}/{self.repository}/issues/{issue_number}",
498
+ headers=self._rest_headers(),
499
+ json={"body": body},
500
+ )
501
+ response.raise_for_status()
502
+ return response.json()
503
+
504
+ async def _comment_issue(self, client: httpx.AsyncClient, issue_number: str, text: str) -> None:
505
+ response = await client.post(
506
+ f"https://api.github.com/repos/{self.owner}/{self.repository}/issues/{issue_number}/comments",
507
+ headers=self._rest_headers(),
508
+ json={"body": text},
509
+ )
510
+ response.raise_for_status()
511
+
512
+ def _item_status_from_issue(self, issue: dict[str, Any], item: CreatedItem) -> ItemStatus:
513
+ return ItemStatus(
514
+ provider=self.name,
515
+ id=str(issue["number"]),
516
+ url=issue.get("html_url") or item.url,
517
+ title=issue.get("title") or item.title,
518
+ closed=issue.get("state") == "closed",
519
+ tasks=_task_statuses_from_issue_body(issue.get("body") or ""),
520
+ )
521
+
522
+ def _rest_headers(self) -> dict[str, str]:
523
+ return {
524
+ "Authorization": f"Bearer {self.token}",
525
+ "Accept": "application/vnd.github+json",
526
+ "X-GitHub-Api-Version": "2022-11-28",
527
+ }
528
+
529
+
530
+ def _build_issue_body(requirement: Requirement) -> str:
531
+ parts = [requirement.description] if requirement.description else []
532
+ if requirement.tasks:
533
+ tasks = "\n".join(f"- [{'x' if task.done else ' '}] {task.title}" for task in requirement.tasks)
534
+ parts.append(f"## Tasks\n\n{tasks}")
535
+ return "\n\n".join(parts)
536
+
537
+
538
+ def _task_statuses_from_issue_body(body: str) -> list[TaskStatus]:
539
+ return [TaskStatus(id=task_id, title=title, done=done) for task_id, title, done, _ in _task_entries(body)]
540
+
541
+
542
+ def _task_entries(body: str) -> list[tuple[str, str, bool, int]]:
543
+ tasks: list[tuple[str, str, bool, int]] = []
544
+ in_tasks = False
545
+ for line_index, line in enumerate(body.splitlines()):
546
+ if line.strip() == "## Tasks":
547
+ in_tasks = True
548
+ continue
549
+ if in_tasks and line.startswith("## "):
550
+ break
551
+ if in_tasks and (match := _TASK_PATTERN.fullmatch(line)):
552
+ tasks.append(
553
+ (
554
+ f"task-{len(tasks) + 1}",
555
+ match.group(2).strip(),
556
+ match.group(1).casefold() == "x",
557
+ line_index,
558
+ )
559
+ )
560
+ return tasks
561
+
562
+
563
+ def _complete_task_in_issue_body(body: str, task_ref: str) -> str:
564
+ tasks = _task_entries(body)
565
+ exact_matches = [task for task in tasks if task[0] == task_ref or task[1].casefold() == task_ref.casefold()]
566
+ if len(exact_matches) > 1:
567
+ raise ValueError(f"Multiple tasks matched exactly: {task_ref}")
568
+
569
+ matches = exact_matches or [task for task in tasks if task_ref.casefold() in task[1].casefold()]
570
+ if len(matches) > 1:
571
+ raise ValueError(f"Multiple tasks matched '{task_ref}': {', '.join(task[1] for task in matches)}")
572
+ if not matches:
573
+ raise ValueError(f"Task not found: {task_ref}")
574
+
575
+ _, _, done, line_index = matches[0]
576
+ if done:
577
+ return body
578
+
579
+ lines = body.splitlines(keepends=True)
580
+ lines[line_index] = lines[line_index].replace("- [ ]", "- [x]", 1)
581
+ return "".join(lines)
582
+
583
+
584
+ def _sync_tasks_in_issue_body(body: str, requirement: Requirement) -> str:
585
+ lines = body.splitlines()
586
+ start = next((index for index, line in enumerate(lines) if line.strip() == "## Tasks"), None)
587
+ after_start = (start if start is not None else -1) + 1
588
+ end = next((index for index in range(after_start, len(lines)) if lines[index].startswith("## ")), len(lines))
589
+ completed: dict[str, list[bool]] = {}
590
+ for _, title, done, _ in _task_entries(body):
591
+ completed.setdefault(title.casefold(), []).append(done)
592
+
593
+ task_lines = []
594
+ for task in requirement.tasks:
595
+ states = completed.get(task.title.casefold(), [])
596
+ done = states.pop(0) if states else task.done
597
+ task_lines.append(f"- [{'x' if done else ' '}] {task.title}")
598
+ section = ["## Tasks", "", *task_lines, *([""] if end < len(lines) else [])] if task_lines else []
599
+
600
+ if start is None:
601
+ updated = [*lines, *([""] if lines and task_lines else []), *section]
602
+ else:
603
+ updated = [*lines[:start], *section, *lines[end:]]
604
+ while updated and not updated[-1]:
605
+ updated.pop()
606
+ return "\n".join(updated) + ("\n" if updated and body.endswith("\n") else "")