github-task-protocol 1.0.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.
- github_task_protocol-1.0.1.dist-info/METADATA +88 -0
- github_task_protocol-1.0.1.dist-info/RECORD +16 -0
- github_task_protocol-1.0.1.dist-info/WHEEL +5 -0
- github_task_protocol-1.0.1.dist-info/entry_points.txt +2 -0
- github_task_protocol-1.0.1.dist-info/licenses/LICENSE +21 -0
- gtp/__init__.py +3 -0
- gtp/__main__.py +4 -0
- gtp/carrier.py +107 -0
- gtp/cli.py +52 -0
- gtp/github.py +214 -0
- gtp/model.py +93 -0
- gtp/presentation.py +419 -0
- gtp/reducer.py +184 -0
- gtp/schema.py +211 -0
- gtp/status.py +552 -0
- gtp/urls.py +100 -0
gtp/status.py
ADDED
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
"""Status application service joining prefix history with live GitHub observations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
from urllib.parse import unquote
|
|
8
|
+
|
|
9
|
+
from .github import AcquisitionError, GitHubClient
|
|
10
|
+
from .model import (
|
|
11
|
+
Diagnostic,
|
|
12
|
+
FoldContext,
|
|
13
|
+
IncompleteSnapshotError,
|
|
14
|
+
RecordObservation,
|
|
15
|
+
SuccessorFact,
|
|
16
|
+
)
|
|
17
|
+
from .reducer import fold_comments, historical_state, successor_refs
|
|
18
|
+
from .urls import parse_github_url
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class StatusResult:
|
|
23
|
+
issue_url: str
|
|
24
|
+
state: str | None
|
|
25
|
+
diagnostics: list[Diagnostic] = field(default_factory=list)
|
|
26
|
+
current: dict[str, Any] = field(default_factory=dict)
|
|
27
|
+
acquisition_errors: list[dict[str, Any]] = field(default_factory=list)
|
|
28
|
+
|
|
29
|
+
def projection(self) -> dict[str, Any]:
|
|
30
|
+
return {
|
|
31
|
+
"issue_url": self.issue_url,
|
|
32
|
+
"state": self.state,
|
|
33
|
+
"diagnostics": [item.projection() for item in self.diagnostics],
|
|
34
|
+
"current": self.current,
|
|
35
|
+
"acquisition": {
|
|
36
|
+
"complete": not self.acquisition_errors,
|
|
37
|
+
"errors": self.acquisition_errors,
|
|
38
|
+
},
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class _DoneLive:
|
|
44
|
+
observation: RecordObservation
|
|
45
|
+
pr: dict[str, Any] | None = None
|
|
46
|
+
diagnostics: list[Diagnostic] = field(default_factory=list)
|
|
47
|
+
terminal_at: str | None = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _record_projection(observation: RecordObservation | None) -> dict[str, Any] | None:
|
|
51
|
+
if observation is None:
|
|
52
|
+
return None
|
|
53
|
+
content_fields = {
|
|
54
|
+
"contract": ("goal", "scope", "done_conditions"),
|
|
55
|
+
"start": ("contract_ref", "branch"),
|
|
56
|
+
"done": ("pr_ref", "head_sha", "evidence"),
|
|
57
|
+
"stop": ("reason", "successor_ref"),
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
"id": observation.id,
|
|
61
|
+
"type": observation.type,
|
|
62
|
+
"url": observation.comment.url,
|
|
63
|
+
"aliases": list(observation.alias_urls),
|
|
64
|
+
"comment_id": observation.comment.id,
|
|
65
|
+
"content": {
|
|
66
|
+
key: observation.record[key]
|
|
67
|
+
for key in content_fields[observation.type]
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _diagnostic(token: str, *urls: str, **detail: Any) -> Diagnostic:
|
|
73
|
+
return Diagnostic(token, tuple(dict.fromkeys(urls)), detail)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _acquisition(issue_url: str, error: AcquisitionError) -> StatusResult:
|
|
77
|
+
item: dict[str, Any] = {
|
|
78
|
+
"code": "acquisition_incomplete",
|
|
79
|
+
"resource": error.resource,
|
|
80
|
+
"message": str(error),
|
|
81
|
+
}
|
|
82
|
+
if error.status is not None:
|
|
83
|
+
item["status"] = error.status
|
|
84
|
+
return StatusResult(issue_url, None, acquisition_errors=[item])
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _repo_matches(resource: dict[str, Any], expected_id: int) -> bool:
|
|
88
|
+
return isinstance(resource, dict) and resource.get("id") == expected_id
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _pr_matches(pr: dict[str, Any], repo_id: int, branch: str) -> bool:
|
|
92
|
+
return (
|
|
93
|
+
pr.get("base", {}).get("repo", {}).get("id") == repo_id
|
|
94
|
+
and pr.get("head", {}).get("repo", {}).get("id") == repo_id
|
|
95
|
+
and pr.get("head", {}).get("ref") == branch
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _path_in_scope(path: str, scope: list[str]) -> bool:
|
|
100
|
+
for entry in scope:
|
|
101
|
+
if entry == "." or path == entry or (entry.endswith("/") and path.startswith(entry)):
|
|
102
|
+
return True
|
|
103
|
+
return False
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _scope_diagnostics(
|
|
107
|
+
client: GitHubClient,
|
|
108
|
+
owner: str,
|
|
109
|
+
repo: str,
|
|
110
|
+
contract: RecordObservation,
|
|
111
|
+
pr: dict[str, Any],
|
|
112
|
+
anchor_url: str,
|
|
113
|
+
) -> list[Diagnostic]:
|
|
114
|
+
number = pr.get("number")
|
|
115
|
+
pr_url = pr.get("html_url")
|
|
116
|
+
if not isinstance(number, int) or not isinstance(pr_url, str):
|
|
117
|
+
raise AcquisitionError(anchor_url, "pull request identity missing")
|
|
118
|
+
files = client.pull_request_files(owner, repo, number)
|
|
119
|
+
observed_paths: list[str] = []
|
|
120
|
+
for item in files:
|
|
121
|
+
if not isinstance(item, dict) or not isinstance(item.get("filename"), str):
|
|
122
|
+
raise AcquisitionError(pr_url, "pull request file entry is incomplete")
|
|
123
|
+
observed_paths.append(item["filename"])
|
|
124
|
+
previous = item.get("previous_filename")
|
|
125
|
+
if previous is not None:
|
|
126
|
+
if not isinstance(previous, str):
|
|
127
|
+
raise AcquisitionError(pr_url, "pull request rename entry is incomplete")
|
|
128
|
+
observed_paths.append(previous)
|
|
129
|
+
outside = sorted(
|
|
130
|
+
{path for path in observed_paths if not _path_in_scope(path, contract.record["scope"])}
|
|
131
|
+
)
|
|
132
|
+
if outside:
|
|
133
|
+
return [_diagnostic("invalid_binding", anchor_url, pr_url, paths=outside)]
|
|
134
|
+
return []
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _missing_is_mismatch(error: AcquisitionError) -> bool:
|
|
138
|
+
return error.status == 404
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _successor_context(
|
|
142
|
+
client: GitHubClient,
|
|
143
|
+
comments: list[Any],
|
|
144
|
+
) -> dict[str, SuccessorFact]:
|
|
145
|
+
facts: dict[str, SuccessorFact] = {}
|
|
146
|
+
for url in successor_refs(comments):
|
|
147
|
+
parsed = parse_github_url(url, "issue")
|
|
148
|
+
assert parsed is not None
|
|
149
|
+
repository = client.repository(parsed.owner, parsed.repo)
|
|
150
|
+
try:
|
|
151
|
+
issue = client.issue(parsed.owner, parsed.repo, parsed.number or 0)
|
|
152
|
+
except AcquisitionError as error:
|
|
153
|
+
if _missing_is_mismatch(error):
|
|
154
|
+
facts[url] = SuccessorFact(url, False, repository_id=repository.get("id"))
|
|
155
|
+
continue
|
|
156
|
+
raise
|
|
157
|
+
facts[url] = SuccessorFact(
|
|
158
|
+
url,
|
|
159
|
+
"pull_request" not in issue,
|
|
160
|
+
repository_id=repository.get("id"),
|
|
161
|
+
issue_id=issue.get("id"),
|
|
162
|
+
created_at=issue.get("created_at"),
|
|
163
|
+
)
|
|
164
|
+
return facts
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _live_evidence(
|
|
168
|
+
client: GitHubClient,
|
|
169
|
+
issue_repo_id: int,
|
|
170
|
+
contract: RecordObservation,
|
|
171
|
+
done: RecordObservation,
|
|
172
|
+
) -> tuple[list[Diagnostic], list[str]]:
|
|
173
|
+
diagnostics: list[Diagnostic] = []
|
|
174
|
+
completed_at: list[str] = []
|
|
175
|
+
head_sha = done.record["head_sha"]
|
|
176
|
+
for condition_id in sorted(contract.record["done_conditions"]):
|
|
177
|
+
condition = contract.record["done_conditions"][condition_id]
|
|
178
|
+
kind = condition["evidence_kind"]
|
|
179
|
+
url = done.record["evidence"][condition_id]
|
|
180
|
+
parsed = parse_github_url(url, kind)
|
|
181
|
+
if parsed is None:
|
|
182
|
+
diagnostics.append(_diagnostic("invalid_evidence", done.comment.url, url))
|
|
183
|
+
continue
|
|
184
|
+
repository = client.repository(parsed.owner, parsed.repo)
|
|
185
|
+
if not _repo_matches(repository, issue_repo_id):
|
|
186
|
+
diagnostics.append(_diagnostic("invalid_evidence", done.comment.url, url))
|
|
187
|
+
continue
|
|
188
|
+
if kind == "artifact":
|
|
189
|
+
if parsed.sha != head_sha:
|
|
190
|
+
diagnostics.append(_diagnostic("stale_evidence", done.comment.url, url))
|
|
191
|
+
continue
|
|
192
|
+
try:
|
|
193
|
+
resource = client.artifact(
|
|
194
|
+
parsed.owner,
|
|
195
|
+
parsed.repo,
|
|
196
|
+
unquote(parsed.path or "", encoding="utf-8", errors="strict"),
|
|
197
|
+
head_sha,
|
|
198
|
+
)
|
|
199
|
+
except AcquisitionError as error:
|
|
200
|
+
if _missing_is_mismatch(error):
|
|
201
|
+
diagnostics.append(_diagnostic("invalid_evidence", done.comment.url, url))
|
|
202
|
+
continue
|
|
203
|
+
raise
|
|
204
|
+
if not isinstance(resource, dict) or resource.get("type") != "file":
|
|
205
|
+
diagnostics.append(_diagnostic("invalid_evidence", done.comment.url, url))
|
|
206
|
+
else:
|
|
207
|
+
try:
|
|
208
|
+
resource = client.check_run(parsed.owner, parsed.repo, parsed.number or 0)
|
|
209
|
+
except AcquisitionError as error:
|
|
210
|
+
if _missing_is_mismatch(error):
|
|
211
|
+
diagnostics.append(_diagnostic("invalid_evidence", done.comment.url, url))
|
|
212
|
+
continue
|
|
213
|
+
raise
|
|
214
|
+
if resource.get("head_sha") != head_sha:
|
|
215
|
+
diagnostics.append(_diagnostic("stale_evidence", done.comment.url, url))
|
|
216
|
+
elif resource.get("status") != "completed":
|
|
217
|
+
diagnostics.append(_diagnostic("invalid_evidence", done.comment.url, url))
|
|
218
|
+
elif resource.get("conclusion") != "success":
|
|
219
|
+
diagnostics.append(_diagnostic("invalid_evidence", done.comment.url, url))
|
|
220
|
+
elif not isinstance(resource.get("completed_at"), str):
|
|
221
|
+
raise AcquisitionError(url, "completed Check Run is missing completed_at")
|
|
222
|
+
else:
|
|
223
|
+
completed_at.append(resource["completed_at"])
|
|
224
|
+
return diagnostics, completed_at
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _evaluate_done(
|
|
228
|
+
client: GitHubClient,
|
|
229
|
+
repo_id: int,
|
|
230
|
+
branch_name: str,
|
|
231
|
+
contract: RecordObservation,
|
|
232
|
+
done: RecordObservation,
|
|
233
|
+
) -> _DoneLive:
|
|
234
|
+
result = _DoneLive(done)
|
|
235
|
+
pr_url = parse_github_url(done.record["pr_ref"], "pr")
|
|
236
|
+
assert pr_url is not None
|
|
237
|
+
pr_repo = client.repository(pr_url.owner, pr_url.repo)
|
|
238
|
+
try:
|
|
239
|
+
pr = client.pull_request(pr_url.owner, pr_url.repo, pr_url.number or 0)
|
|
240
|
+
except AcquisitionError as error:
|
|
241
|
+
if _missing_is_mismatch(error):
|
|
242
|
+
result.diagnostics.append(
|
|
243
|
+
_diagnostic("invalid_binding", done.comment.url, done.record["pr_ref"])
|
|
244
|
+
)
|
|
245
|
+
return result
|
|
246
|
+
raise
|
|
247
|
+
result.pr = pr
|
|
248
|
+
if not _repo_matches(pr_repo, repo_id) or not _pr_matches(pr, repo_id, branch_name):
|
|
249
|
+
result.diagnostics.append(
|
|
250
|
+
_diagnostic("invalid_binding", done.comment.url, done.record["pr_ref"])
|
|
251
|
+
)
|
|
252
|
+
return result
|
|
253
|
+
if pr.get("head", {}).get("sha") != done.record["head_sha"]:
|
|
254
|
+
result.diagnostics.append(
|
|
255
|
+
_diagnostic("stale_evidence", done.comment.url, done.record["pr_ref"])
|
|
256
|
+
)
|
|
257
|
+
return result
|
|
258
|
+
merged_at = pr.get("merged_at")
|
|
259
|
+
if isinstance(merged_at, str) and merged_at < done.comment.created_at:
|
|
260
|
+
result.diagnostics.append(
|
|
261
|
+
_diagnostic("terminal_violation", done.comment.url, done.record["pr_ref"])
|
|
262
|
+
)
|
|
263
|
+
return result
|
|
264
|
+
scope_diagnostics = _scope_diagnostics(
|
|
265
|
+
client,
|
|
266
|
+
pr_url.owner,
|
|
267
|
+
pr_url.repo,
|
|
268
|
+
contract,
|
|
269
|
+
pr,
|
|
270
|
+
done.comment.url,
|
|
271
|
+
)
|
|
272
|
+
evidence_diagnostics: list[Diagnostic] = []
|
|
273
|
+
check_times: list[str] = []
|
|
274
|
+
if not scope_diagnostics:
|
|
275
|
+
evidence_diagnostics, check_times = _live_evidence(
|
|
276
|
+
client, repo_id, contract, done
|
|
277
|
+
)
|
|
278
|
+
pr_after = client.pull_request(pr_url.owner, pr_url.repo, pr_url.number or 0)
|
|
279
|
+
if pr_after.get("head", {}).get("sha") != pr.get("head", {}).get("sha"):
|
|
280
|
+
raise AcquisitionError(done.record["pr_ref"], "bound pull request head changed during acquisition")
|
|
281
|
+
result.pr = pr_after
|
|
282
|
+
if scope_diagnostics:
|
|
283
|
+
result.diagnostics.extend(scope_diagnostics)
|
|
284
|
+
return result
|
|
285
|
+
if evidence_diagnostics:
|
|
286
|
+
if pr.get("merged_at"):
|
|
287
|
+
urls = [url for item in evidence_diagnostics for url in item.urls]
|
|
288
|
+
result.diagnostics.append(_diagnostic("invalid_evidence", *urls))
|
|
289
|
+
else:
|
|
290
|
+
result.diagnostics.extend(evidence_diagnostics)
|
|
291
|
+
return result
|
|
292
|
+
merged_at = pr_after.get("merged_at")
|
|
293
|
+
if isinstance(merged_at, str):
|
|
294
|
+
result.terminal_at = max(done.comment.created_at, merged_at, *check_times)
|
|
295
|
+
return result
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _terminal_violations(
|
|
299
|
+
diagnostics: list[Diagnostic],
|
|
300
|
+
recognized_comments: list[Any],
|
|
301
|
+
terminal_at: str,
|
|
302
|
+
terminal_record: RecordObservation,
|
|
303
|
+
) -> list[Diagnostic]:
|
|
304
|
+
result = list(diagnostics)
|
|
305
|
+
terminal_aliases = set(terminal_record.alias_urls)
|
|
306
|
+
for comment in recognized_comments:
|
|
307
|
+
if comment.created_at > terminal_at and comment.url not in terminal_aliases:
|
|
308
|
+
item = _diagnostic("terminal_violation", comment.url)
|
|
309
|
+
if item not in result:
|
|
310
|
+
result.append(item)
|
|
311
|
+
return result
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _stop_diagnostics(
|
|
315
|
+
client: GitHubClient,
|
|
316
|
+
owner: str,
|
|
317
|
+
repo: str,
|
|
318
|
+
repo_id: int,
|
|
319
|
+
stop: RecordObservation,
|
|
320
|
+
start: RecordObservation | None,
|
|
321
|
+
context: FoldContext,
|
|
322
|
+
) -> list[Diagnostic]:
|
|
323
|
+
diagnostics: list[Diagnostic] = []
|
|
324
|
+
if stop.record["reason"] == "superseded":
|
|
325
|
+
fact = context.successors[stop.record["successor_ref"]]
|
|
326
|
+
if (
|
|
327
|
+
not fact.exists
|
|
328
|
+
or fact.repository_id != repo_id
|
|
329
|
+
or fact.issue_id == context.issue_id
|
|
330
|
+
or context.issue_created_at is None
|
|
331
|
+
or fact.created_at is None
|
|
332
|
+
or not (context.issue_created_at < fact.created_at <= stop.comment.created_at)
|
|
333
|
+
):
|
|
334
|
+
diagnostics.append(
|
|
335
|
+
_diagnostic("invalid_binding", stop.comment.url, fact.url)
|
|
336
|
+
)
|
|
337
|
+
return diagnostics
|
|
338
|
+
if start is None:
|
|
339
|
+
return diagnostics
|
|
340
|
+
branch_name = start.record["branch"]
|
|
341
|
+
candidates = [
|
|
342
|
+
pr
|
|
343
|
+
for pr in client.pull_requests(owner, repo, branch_name)
|
|
344
|
+
if _pr_matches(pr, repo_id, branch_name) and isinstance(pr.get("merged_at"), str)
|
|
345
|
+
]
|
|
346
|
+
for pr in candidates:
|
|
347
|
+
diagnostics.append(_diagnostic("terminal_violation", stop.comment.url, pr["html_url"]))
|
|
348
|
+
return diagnostics
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _evaluate_acquired(
|
|
352
|
+
client: GitHubClient,
|
|
353
|
+
issue_url: str,
|
|
354
|
+
parsed: Any,
|
|
355
|
+
repository: dict[str, Any],
|
|
356
|
+
issue: dict[str, Any],
|
|
357
|
+
comments: list[Any],
|
|
358
|
+
) -> StatusResult:
|
|
359
|
+
if "pull_request" in issue:
|
|
360
|
+
return StatusResult(
|
|
361
|
+
issue_url,
|
|
362
|
+
None,
|
|
363
|
+
acquisition_errors=[{"code": "invalid_issue_resource", "resource": issue_url}],
|
|
364
|
+
)
|
|
365
|
+
repo_id = repository.get("id")
|
|
366
|
+
if not isinstance(repo_id, int):
|
|
367
|
+
return StatusResult(
|
|
368
|
+
issue_url,
|
|
369
|
+
None,
|
|
370
|
+
acquisition_errors=[{
|
|
371
|
+
"code": "acquisition_incomplete",
|
|
372
|
+
"resource": repository.get("url", issue_url),
|
|
373
|
+
"message": "repository id missing",
|
|
374
|
+
}],
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
try:
|
|
378
|
+
context = FoldContext(
|
|
379
|
+
issue_url=issue_url,
|
|
380
|
+
issue_id=issue.get("id"),
|
|
381
|
+
issue_created_at=issue.get("created_at"),
|
|
382
|
+
repository_id=repo_id,
|
|
383
|
+
successors=_successor_context(client, comments),
|
|
384
|
+
)
|
|
385
|
+
fold = fold_comments(comments, context)
|
|
386
|
+
except IncompleteSnapshotError as error:
|
|
387
|
+
return _acquisition(issue_url, AcquisitionError(issue_url, str(error)))
|
|
388
|
+
except AcquisitionError as error:
|
|
389
|
+
return _acquisition(issue_url, error)
|
|
390
|
+
|
|
391
|
+
active_contract = fold.bound_contract or (
|
|
392
|
+
fold.active["contract"][0] if len(fold.active["contract"]) == 1 else None
|
|
393
|
+
)
|
|
394
|
+
active_done = fold.active["done"][0] if len(fold.active["done"]) == 1 else None
|
|
395
|
+
current: dict[str, Any] = {
|
|
396
|
+
"contract": _record_projection(active_contract),
|
|
397
|
+
"start": _record_projection(fold.bound_start),
|
|
398
|
+
"done": _record_projection(active_done),
|
|
399
|
+
"stop": _record_projection(fold.terminal_stop),
|
|
400
|
+
}
|
|
401
|
+
base_state = historical_state(fold)
|
|
402
|
+
if base_state in {"unmanaged", "ready"}:
|
|
403
|
+
return StatusResult(issue_url, base_state, list(fold.diagnostics), current)
|
|
404
|
+
if fold.bound_start is None or fold.bound_contract is None:
|
|
405
|
+
if fold.terminal_stop is not None:
|
|
406
|
+
try:
|
|
407
|
+
stop_diagnostics = _stop_diagnostics(
|
|
408
|
+
client,
|
|
409
|
+
parsed.owner,
|
|
410
|
+
parsed.repo,
|
|
411
|
+
repo_id,
|
|
412
|
+
fold.terminal_stop,
|
|
413
|
+
fold.bound_start,
|
|
414
|
+
context,
|
|
415
|
+
)
|
|
416
|
+
except AcquisitionError as error:
|
|
417
|
+
return _acquisition(issue_url, error)
|
|
418
|
+
if stop_diagnostics:
|
|
419
|
+
return StatusResult(issue_url, "halt", stop_diagnostics, current)
|
|
420
|
+
return StatusResult(
|
|
421
|
+
issue_url, "stopped", [*fold.diagnostics, *stop_diagnostics], current
|
|
422
|
+
)
|
|
423
|
+
return StatusResult(issue_url, "halt", list(fold.diagnostics), current)
|
|
424
|
+
|
|
425
|
+
branch_name = fold.bound_start.record["branch"]
|
|
426
|
+
current["branch"] = {"name": branch_name}
|
|
427
|
+
if fold.terminal_stop is not None:
|
|
428
|
+
try:
|
|
429
|
+
stop_diagnostics = _stop_diagnostics(
|
|
430
|
+
client, parsed.owner, parsed.repo, repo_id, fold.terminal_stop, fold.bound_start, context
|
|
431
|
+
)
|
|
432
|
+
except AcquisitionError as error:
|
|
433
|
+
return _acquisition(issue_url, error)
|
|
434
|
+
if stop_diagnostics:
|
|
435
|
+
return StatusResult(issue_url, "halt", [*fold.diagnostics, *stop_diagnostics], current)
|
|
436
|
+
return StatusResult(issue_url, "stopped", list(fold.diagnostics), current)
|
|
437
|
+
|
|
438
|
+
if fold.diagnostics:
|
|
439
|
+
return StatusResult(issue_url, "halt", list(fold.diagnostics), current)
|
|
440
|
+
|
|
441
|
+
try:
|
|
442
|
+
if active_done is not None:
|
|
443
|
+
live = _evaluate_done(client, repo_id, branch_name, fold.bound_contract, active_done)
|
|
444
|
+
if live.pr is not None:
|
|
445
|
+
current["bound_pr"] = live.pr.get("html_url")
|
|
446
|
+
current["bound_pr_head_sha"] = live.pr.get("head", {}).get("sha")
|
|
447
|
+
if live.diagnostics:
|
|
448
|
+
return StatusResult(issue_url, "halt", live.diagnostics, current)
|
|
449
|
+
if live.terminal_at is not None:
|
|
450
|
+
diagnostics = _terminal_violations(
|
|
451
|
+
fold.diagnostics, fold.recognized_comments, live.terminal_at, active_done
|
|
452
|
+
)
|
|
453
|
+
return StatusResult(issue_url, "done", diagnostics, current)
|
|
454
|
+
if live.pr and live.pr.get("merged_at"):
|
|
455
|
+
return StatusResult(issue_url, "in_progress", [], current)
|
|
456
|
+
branch = client.branch(parsed.owner, parsed.repo, branch_name)
|
|
457
|
+
current["branch"]["exists"] = branch is not None
|
|
458
|
+
if branch is None:
|
|
459
|
+
return StatusResult(
|
|
460
|
+
issue_url,
|
|
461
|
+
"halt",
|
|
462
|
+
[_diagnostic("invalid_binding", fold.bound_start.comment.url)],
|
|
463
|
+
current,
|
|
464
|
+
)
|
|
465
|
+
return StatusResult(issue_url, "in_progress", [], current)
|
|
466
|
+
|
|
467
|
+
branch = client.branch(parsed.owner, parsed.repo, branch_name)
|
|
468
|
+
observed_candidates = client.pull_requests(parsed.owner, parsed.repo, branch_name)
|
|
469
|
+
invalid_candidates = [
|
|
470
|
+
pr for pr in observed_candidates if not _pr_matches(pr, repo_id, branch_name)
|
|
471
|
+
]
|
|
472
|
+
if invalid_candidates:
|
|
473
|
+
urls = [fold.bound_start.comment.url] + [
|
|
474
|
+
pr.get("html_url", issue_url) for pr in invalid_candidates
|
|
475
|
+
]
|
|
476
|
+
return StatusResult(
|
|
477
|
+
issue_url,
|
|
478
|
+
"halt",
|
|
479
|
+
[_diagnostic("invalid_binding", *urls)],
|
|
480
|
+
current,
|
|
481
|
+
)
|
|
482
|
+
candidates = observed_candidates
|
|
483
|
+
current["branch"]["exists"] = branch is not None
|
|
484
|
+
current["pr_candidates"] = [pr.get("html_url") for pr in candidates]
|
|
485
|
+
if len(candidates) > 1:
|
|
486
|
+
urls = [fold.bound_start.comment.url] + [pr["html_url"] for pr in candidates]
|
|
487
|
+
return StatusResult(issue_url, "halt", [_diagnostic("invalid_binding", *urls)], current)
|
|
488
|
+
if len(candidates) == 1 and candidates[0].get("merged_at"):
|
|
489
|
+
return StatusResult(
|
|
490
|
+
issue_url,
|
|
491
|
+
"halt",
|
|
492
|
+
[_diagnostic("terminal_violation", fold.bound_start.comment.url, candidates[0]["html_url"])],
|
|
493
|
+
current,
|
|
494
|
+
)
|
|
495
|
+
if len(candidates) == 1:
|
|
496
|
+
scope_diagnostics = _scope_diagnostics(
|
|
497
|
+
client,
|
|
498
|
+
parsed.owner,
|
|
499
|
+
parsed.repo,
|
|
500
|
+
fold.bound_contract,
|
|
501
|
+
candidates[0],
|
|
502
|
+
fold.bound_start.comment.url,
|
|
503
|
+
)
|
|
504
|
+
if scope_diagnostics:
|
|
505
|
+
return StatusResult(issue_url, "halt", scope_diagnostics, current)
|
|
506
|
+
if branch is None:
|
|
507
|
+
return StatusResult(
|
|
508
|
+
issue_url,
|
|
509
|
+
"halt",
|
|
510
|
+
[_diagnostic("invalid_binding", fold.bound_start.comment.url)],
|
|
511
|
+
current,
|
|
512
|
+
)
|
|
513
|
+
return StatusResult(issue_url, "in_progress", [], current)
|
|
514
|
+
except AcquisitionError as error:
|
|
515
|
+
return _acquisition(issue_url, error)
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _issue_snapshot_key(issue: dict[str, Any]) -> tuple[int, str, str]:
|
|
519
|
+
issue_id = issue.get("id")
|
|
520
|
+
created_at = issue.get("created_at")
|
|
521
|
+
updated_at = issue.get("updated_at", created_at)
|
|
522
|
+
if not isinstance(issue_id, int) or not isinstance(created_at, str) or not isinstance(updated_at, str):
|
|
523
|
+
raise AcquisitionError(str(issue.get("url", "issue")), "issue snapshot fields missing")
|
|
524
|
+
return issue_id, created_at, updated_at
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def evaluate_issue(client: GitHubClient, issue_url: str) -> StatusResult:
|
|
528
|
+
parsed = parse_github_url(issue_url, "issue")
|
|
529
|
+
if parsed is None:
|
|
530
|
+
return StatusResult(
|
|
531
|
+
issue_url,
|
|
532
|
+
None,
|
|
533
|
+
acquisition_errors=[{"code": "invalid_issue_url", "resource": issue_url}],
|
|
534
|
+
)
|
|
535
|
+
try:
|
|
536
|
+
repository = client.repository(parsed.owner, parsed.repo)
|
|
537
|
+
issue = client.issue(parsed.owner, parsed.repo, parsed.number or 0)
|
|
538
|
+
initial_key = _issue_snapshot_key(issue)
|
|
539
|
+
comments = client.comments(parsed.owner, parsed.repo, parsed.number or 0)
|
|
540
|
+
except AcquisitionError as error:
|
|
541
|
+
return _acquisition(issue_url, error)
|
|
542
|
+
|
|
543
|
+
result = _evaluate_acquired(client, issue_url, parsed, repository, issue, comments)
|
|
544
|
+
if result.state is None:
|
|
545
|
+
return result
|
|
546
|
+
try:
|
|
547
|
+
issue_after = client.issue(parsed.owner, parsed.repo, parsed.number or 0)
|
|
548
|
+
if _issue_snapshot_key(issue_after) != initial_key:
|
|
549
|
+
raise AcquisitionError(issue_url, "issue snapshot changed during acquisition")
|
|
550
|
+
except AcquisitionError as error:
|
|
551
|
+
return _acquisition(issue_url, error)
|
|
552
|
+
return result
|
gtp/urls.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Canonical GitHub URL profiles used by GTP v1."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
import re
|
|
7
|
+
from urllib.parse import unquote_to_bytes, urlsplit
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
_DECIMAL = r"[1-9][0-9]*"
|
|
11
|
+
_SHA = r"[0-9a-f]{40}"
|
|
12
|
+
_RESOURCE_PATTERNS = {
|
|
13
|
+
"issue": re.compile(rf"^/([^/]+)/([^/]+)/issues/({_DECIMAL})$"),
|
|
14
|
+
"pr": re.compile(rf"^/([^/]+)/([^/]+)/pull/({_DECIMAL})$"),
|
|
15
|
+
"check": re.compile(rf"^/([^/]+)/([^/]+)/runs/({_DECIMAL})$"),
|
|
16
|
+
"artifact": re.compile(rf"^/([^/]+)/([^/]+)/blob/({_SHA})/(.+)$"),
|
|
17
|
+
}
|
|
18
|
+
_COMMENT_PATTERN = re.compile(rf"^/([^/]+)/([^/]+)/issues/({_DECIMAL})$")
|
|
19
|
+
_COMMENT_FRAGMENT = re.compile(rf"^issuecomment-({_DECIMAL})$")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class GitHubUrl:
|
|
24
|
+
kind: str
|
|
25
|
+
owner: str
|
|
26
|
+
repo: str
|
|
27
|
+
number: int | None = None
|
|
28
|
+
sha: str | None = None
|
|
29
|
+
path: str | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _literal_segment(value: str) -> bool:
|
|
33
|
+
return bool(value) and value not in {".", ".."} and not any(
|
|
34
|
+
char in value for char in ("%", "/", "\\", "\x00")
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _artifact_path_valid(raw_path: str) -> bool:
|
|
39
|
+
if not raw_path or "\\" in raw_path or "\x00" in raw_path:
|
|
40
|
+
return False
|
|
41
|
+
for raw_segment in raw_path.split("/"):
|
|
42
|
+
if not raw_segment:
|
|
43
|
+
return False
|
|
44
|
+
if re.search(r"%(?![0-9A-Fa-f]{2})", raw_segment):
|
|
45
|
+
return False
|
|
46
|
+
try:
|
|
47
|
+
decoded = unquote_to_bytes(raw_segment).decode("utf-8", errors="strict")
|
|
48
|
+
except UnicodeError:
|
|
49
|
+
return False
|
|
50
|
+
if decoded in {".", ".."} or "/" in decoded or "\\" in decoded or "\x00" in decoded:
|
|
51
|
+
return False
|
|
52
|
+
return True
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def parse_github_url(value: object, expected: str | None = None) -> GitHubUrl | None:
|
|
56
|
+
if not isinstance(value, str):
|
|
57
|
+
return None
|
|
58
|
+
try:
|
|
59
|
+
parts = urlsplit(value)
|
|
60
|
+
except ValueError:
|
|
61
|
+
return None
|
|
62
|
+
if (
|
|
63
|
+
parts.scheme != "https"
|
|
64
|
+
or parts.hostname != "github.com"
|
|
65
|
+
or parts.username is not None
|
|
66
|
+
or parts.password is not None
|
|
67
|
+
or parts.port is not None
|
|
68
|
+
or parts.query
|
|
69
|
+
):
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
if expected == "comment" or (expected is None and parts.fragment):
|
|
73
|
+
match = _COMMENT_PATTERN.fullmatch(parts.path)
|
|
74
|
+
fragment = _COMMENT_FRAGMENT.fullmatch(parts.fragment)
|
|
75
|
+
if not match or not fragment:
|
|
76
|
+
return None
|
|
77
|
+
owner, repo, issue_number = match.groups()
|
|
78
|
+
if not _literal_segment(owner) or not _literal_segment(repo):
|
|
79
|
+
return None
|
|
80
|
+
return GitHubUrl("comment", owner, repo, int(issue_number), path=fragment.group(1))
|
|
81
|
+
|
|
82
|
+
if parts.fragment:
|
|
83
|
+
return None
|
|
84
|
+
kinds = [expected] if expected else list(_RESOURCE_PATTERNS)
|
|
85
|
+
for kind in kinds:
|
|
86
|
+
if kind not in _RESOURCE_PATTERNS:
|
|
87
|
+
continue
|
|
88
|
+
match = _RESOURCE_PATTERNS[kind].fullmatch(parts.path)
|
|
89
|
+
if not match:
|
|
90
|
+
continue
|
|
91
|
+
owner, repo, third, *rest = match.groups()
|
|
92
|
+
if not _literal_segment(owner) or not _literal_segment(repo):
|
|
93
|
+
return None
|
|
94
|
+
if kind == "artifact":
|
|
95
|
+
artifact_path = rest[0]
|
|
96
|
+
if not _artifact_path_valid(artifact_path):
|
|
97
|
+
return None
|
|
98
|
+
return GitHubUrl(kind, owner, repo, sha=third, path=artifact_path)
|
|
99
|
+
return GitHubUrl(kind, owner, repo, number=int(third))
|
|
100
|
+
return None
|