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