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.
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