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.
@@ -0,0 +1,88 @@
1
+ Metadata-Version: 2.4
2
+ Name: github-task-protocol
3
+ Version: 1.0.1
4
+ Summary: Reconstruct AI coding task state from GitHub Issue records
5
+ Requires-Python: >=3.11
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Project-URL: Repository, https://github.com/shinya0x00/github-task-protocol
9
+ Description-Content-Type: text/markdown
10
+
11
+ # GitHub Task Protocol
12
+
13
+ GTPは、AIへ実装を任せても、人間が目的・変更範囲・現在地・根拠・未確認事項を理解し、停止・再開・やり直し・mergeの判断を手放さないための小さなprotocolです。
14
+
15
+ > 作業はAIに任せる。判断は手放さない。
16
+
17
+ AIの説明だけを信じるのではなく、GitHub Issue上のRecordと、実際のbranch・PR・commit・Check Runからtask stateを再構成します。GTP自身は変更、完了、mergeの権限を与えません。
18
+
19
+ ## 導入は3手順
20
+
21
+ 1. [`GTP.md`](GTP.md)を導入先repositoryのrootへコピーする。
22
+ 2. 下の共通adapter文を、agentが必ず読む文書へ1段落追加する。
23
+ 3. taskごとにGitHub Issueを1件作り、agentへそのIssue URLを渡す。
24
+
25
+ 共通adapter文:
26
+
27
+ > このrepositoryはrootの`GTP.md`をtask protocolの唯一の正本とする。GitHub Issue URLを受け取ったら、Issue commentをServer Orderで読み、4 Record、6 state、7 halt reasonに従って既存branch・PR・次のprotocol actionを再構成する。Recordを推測、編集、独自拡張せず、矛盾時は原因URLを示して止まり、取得不能はhaltと混同しない。GTPの表示やRecordは変更・完了・mergeの権限を与えない。
28
+
29
+ 配置例は次のとおりです。runtimeごとに異なる指示を作る必要はありません。
30
+
31
+ - Codex: `AGENTS.md`
32
+ - Claude Code: `CLAUDE.md`または`AGENTS.md`
33
+ - Cursor: `AGENTS.md`または`.cursor/rules/gtp.md`
34
+
35
+ ## 4つのRecord
36
+
37
+ | Record | 平易な意味 |
38
+ |---|---|
39
+ | `contract` | 目的、変更してよい範囲、完了条件を固定する |
40
+ | `start` | Contractと唯一の作業branchを結び付ける |
41
+ | `done` | PRのsource headと、条件ごとのEvidenceを提示する |
42
+ | `stop` | 完了を主張せず中止し、必要なら後継Issueを示す |
43
+
44
+ RecordはIssue commentへ人向け要約を先に、機械用JSONを折りたたんで記録します。1 Issue = 1 branch = 1 PRです。
45
+
46
+ ## 6つのstate
47
+
48
+ | state | 平易な意味 |
49
+ |---|---|
50
+ | `unmanaged` | 有効なContractがない |
51
+ | `ready` | ContractはあるがStart前 |
52
+ | `in_progress` | 作業中、またはDone提示後のmerge待ち |
53
+ | `halt` | 特定transitionを矛盾や不適合のため進められない |
54
+ | `done` | Doneのsource headとEvidenceを持つPRがnative mergeされた |
55
+ | `stopped` | Stopにより、このIssueでの作業を終了した |
56
+
57
+ GitHub情報を完全に取得できない場合はstateを推測しません。これは`halt`ではなくAcquisition Errorです。
58
+
59
+ ## CLIは任意の検証器
60
+
61
+ 人間がGTPを使うためにCLIをinstallする必要はありません。`gtp`はagentや自動検査がRecordと現在stateを確認するための、runtime dependency 0の任意toolです。
62
+
63
+ CLIはPyPI公開後、固定versionを指定して実行できます。GTPを使うだけならCLIのinstallは不要です。
64
+
65
+ ```console
66
+ uvx --from github-task-protocol==1.0.1 gtp status <issue-url>
67
+ uvx --from github-task-protocol==1.0.1 gtp check <comment.md>
68
+ ```
69
+
70
+ - `status`はGitHubへGETだけを行い、日本語6項目の後にmachine JSONを出します。
71
+ - `check`は投稿前のMarkdown comment全文をoffline検査します。Issue上でもvalidだとは主張しません。
72
+ - exit code、緑色のCheck Run、Evidence URLは、変更やmergeの許可ではありません。
73
+
74
+ ## 仕様と判断記録
75
+
76
+ protocolの唯一の正本は400行以内の[`GTP.md`](GTP.md)です。Record作成やstate判断に、他の文書は必要ありません。
77
+
78
+ [`DECISIONS.md`](DECISIONS.md)は、設計変更の理由と履歴です。`GTP.md`と意味が衝突する場合は`GTP.md`を優先します。
79
+
80
+ 実GitHubで観測した引き継ぎ結果は[`acceptance/level0/`](acceptance/level0/)にあります。これは仕様の代わりではありません。
81
+
82
+ ## GTPが証明しないこと
83
+
84
+ GTPは、actor本人性、credential安全性、コード品質そのもの、Evidence内容の真実性を証明しません。filesystem削除や本番database操作を物理的に防ぐものでもありません。
85
+
86
+ サンドボックス、最小権限、不可逆操作前の確認、reviewと組み合わせてください。最終的な受理は、人間がPRとEvidenceを読み、GitHubのnative mergeで判断します。
87
+
88
+ License: [MIT](LICENSE)
@@ -0,0 +1,16 @@
1
+ gtp/__init__.py,sha256=p2yfQm17fjTB7nHssAmxA-IfIMg5gIUIfUsc6HA5gwc,58
2
+ gtp/__main__.py,sha256=D31U8_ux95qF64EQ8ReT25nabzxh7ped5avpobXz_bM,49
3
+ gtp/carrier.py,sha256=K4Wi1BGlFZ87VuRYa6CYC-oOuGnEPsFw5-2EkDWAbno,4039
4
+ gtp/cli.py,sha256=OSmVwjr9GiBqBUY64i2VpFV9putlCKGCrfeL_40laUU,1721
5
+ gtp/github.py,sha256=zK4HZ-yATvhoN03j91RYnVZLWaHJT4VyhUVCK3I-75w,8591
6
+ gtp/model.py,sha256=0CnS7k0bMPn3Bw4voZGT-Jgvez65JX6X_hpCeeyyl4Y,2443
7
+ gtp/presentation.py,sha256=F50zagAcedp4xyRIuAS-CKu2s6JXrarhAglnPiT_uzM,17347
8
+ gtp/reducer.py,sha256=YuEq9ylDfkuYGLEa2YEM9PaUOrhxWY3HnU_p5rMK8G0,6997
9
+ gtp/schema.py,sha256=Jjltq80E8HjaUBPWoQJ_YwRM-Lkm85rCaHuv10sB0Sg,7768
10
+ gtp/status.py,sha256=PlubWkx6WB1uVuf5cgSklVjVXlrqoViSHoks7m6v8LA,21434
11
+ gtp/urls.py,sha256=CxheRZ4ND__RMZx-cejSMqk5tCu6aaoin6g3Q_RK2i8,3332
12
+ github_task_protocol-1.0.1.dist-info/METADATA,sha256=yxHr1cvH9lYHwvHr4F6RsxzpdCE7KvzgF27N4bUW0fQ,4687
13
+ github_task_protocol-1.0.1.dist-info/WHEEL,sha256=5CJ4bbElW8cq_GDpNSLjMgfb15S72ImTOLq9c70uj1Q,106
14
+ github_task_protocol-1.0.1.dist-info/entry_points.txt,sha256=ULDQmE66x4_NxwQH1Ru9cEVOJMEUBvT27g2a5FcDrAo,37
15
+ github_task_protocol-1.0.1.dist-info/licenses/LICENSE,sha256=yvK8Xx5r58kF13JNYhXfeRf9IAY9rm-jS3THRU0ttJY,1067
16
+ github_task_protocol-1.0.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: github-task-protocol.build_backend
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ gtp = gtp.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 shinya0x00
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
gtp/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """GitHub Task Protocol reader."""
2
+
3
+ __version__ = "1.0.1"
gtp/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+
4
+ raise SystemExit(main())
gtp/carrier.py ADDED
@@ -0,0 +1,107 @@
1
+ """Exact GTP Carrier classifier shared by check and status."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ import unicodedata
7
+ from typing import Any
8
+
9
+ from .schema import strict_json_loads, validate_record
10
+
11
+
12
+ MARKER = "<!-- gtp-record:v1 -->"
13
+ DETAILS_OPEN = "<details><summary>記録(JSON)</summary>"
14
+ DETAILS_CLOSE = "</details>"
15
+ JSON_OPEN = "```json"
16
+ JSON_CLOSE = "```"
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class CarrierResult:
21
+ recognized: bool
22
+ schema_valid: bool | None
23
+ record: dict[str, Any] | None
24
+ errors: list[dict[str, str]]
25
+
26
+ def projection(self) -> dict[str, Any]:
27
+ result: dict[str, Any] = {
28
+ "recognized": self.recognized,
29
+ "schema_valid": self.schema_valid,
30
+ "contextual_checks": "not_run",
31
+ }
32
+ if self.errors:
33
+ result["errors"] = self.errors
34
+ return result
35
+
36
+
37
+ def _blank(line: str) -> bool:
38
+ return not line.strip()
39
+
40
+
41
+ def classify_carrier(body: str) -> CarrierResult:
42
+ lines = body.split("\n")
43
+ recognized = any(line == MARKER for line in lines)
44
+ if not recognized:
45
+ return CarrierResult(False, None, None, [])
46
+
47
+ nonblank = [index for index, line in enumerate(lines) if not _blank(line)]
48
+ format_errors: list[dict[str, str]] = []
49
+ if len(nonblank) < 7:
50
+ format_errors.append({"code": "invalid_carrier", "path": "$"})
51
+ return CarrierResult(True, False, None, format_errors)
52
+
53
+ marker_index, summary_index = nonblank[0], nonblank[1]
54
+ summary = lines[summary_index]
55
+ if (
56
+ not summary
57
+ or summary != summary.strip()
58
+ or any(unicodedata.category(char) == "Cc" for char in summary)
59
+ ):
60
+ format_errors.append({"code": "invalid_summary", "path": "$.summary"})
61
+ if lines[marker_index] != MARKER:
62
+ format_errors.append({"code": "invalid_marker_position", "path": "$.marker"})
63
+
64
+ details_open_candidates = [index for index in nonblank if lines[index] == DETAILS_OPEN]
65
+ details_close_candidates = [index for index in nonblank if lines[index] == DETAILS_CLOSE]
66
+ if len(details_open_candidates) != 1 or len(details_close_candidates) != 1:
67
+ format_errors.append({"code": "invalid_details_wrapper", "path": "$.details"})
68
+ return CarrierResult(True, False, None, format_errors)
69
+
70
+ opening_candidates = [index for index in nonblank if lines[index] == JSON_OPEN]
71
+ closing_candidates = [index for index in nonblank if lines[index] == JSON_CLOSE]
72
+ if len(opening_candidates) != 1 or len(closing_candidates) != 1:
73
+ format_errors.append({"code": "invalid_json_fence", "path": "$.fence"})
74
+ return CarrierResult(True, False, None, format_errors)
75
+
76
+ details_open = details_open_candidates[0]
77
+ details_close = details_close_candidates[0]
78
+ opening = opening_candidates[0]
79
+ closing = closing_candidates[0]
80
+ expected_prefix = [marker_index, summary_index, details_open, opening]
81
+ expected_suffix = [closing, details_close]
82
+ if (
83
+ nonblank[:4] != expected_prefix
84
+ or nonblank[-2:] != expected_suffix
85
+ or not (marker_index < summary_index < details_open < opening < closing < details_close)
86
+ ):
87
+ format_errors.append({"code": "invalid_carrier_layout", "path": "$"})
88
+ return CarrierResult(True, False, None, format_errors)
89
+
90
+ required_spacing = (
91
+ summary_index == marker_index + 1
92
+ and details_open == summary_index + 2
93
+ and opening == details_open + 2
94
+ and details_close == closing + 2
95
+ )
96
+ if not required_spacing:
97
+ format_errors.append({"code": "invalid_carrier_spacing", "path": "$"})
98
+ if format_errors:
99
+ return CarrierResult(True, False, None, format_errors)
100
+
101
+ value, parse_errors = strict_json_loads("\n".join(lines[opening + 1 : closing]))
102
+ if parse_errors:
103
+ return CarrierResult(True, False, None, parse_errors)
104
+ schema_errors = validate_record(value)
105
+ if schema_errors:
106
+ return CarrierResult(True, False, None, schema_errors)
107
+ return CarrierResult(True, True, value, [])
gtp/cli.py ADDED
@@ -0,0 +1,52 @@
1
+ """GTP command-line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ from pathlib import Path
8
+ import sys
9
+
10
+ from . import __version__
11
+ from .carrier import classify_carrier
12
+ from .github import GitHubClient
13
+ from .presentation import present_check, present_input_error, present_status
14
+ from .status import evaluate_issue
15
+
16
+
17
+ def _emit(lines: list[str], value: object) -> None:
18
+ for line in lines:
19
+ sys.stdout.write(f"{line}\n")
20
+ json.dump(value, sys.stdout, ensure_ascii=False, sort_keys=True, indent=2)
21
+ sys.stdout.write("\n")
22
+
23
+
24
+ def _check(path: str) -> int:
25
+ try:
26
+ body = Path(path).read_text(encoding="utf-8")
27
+ except (OSError, UnicodeError) as error:
28
+ _emit(*present_input_error(str(error)))
29
+ return 2
30
+ result = classify_carrier(body)
31
+ _emit(*present_check(result))
32
+ return 0 if result.recognized and result.schema_valid else 1
33
+
34
+
35
+ def build_parser() -> argparse.ArgumentParser:
36
+ parser = argparse.ArgumentParser(prog="gtp")
37
+ parser.add_argument("--version", action="version", version=__version__)
38
+ subparsers = parser.add_subparsers(dest="command", required=True)
39
+ check = subparsers.add_parser("check", help="validate one complete Markdown comment")
40
+ check.add_argument("comment")
41
+ status = subparsers.add_parser("status", help="reconstruct one GitHub Issue state")
42
+ status.add_argument("issue_url")
43
+ return parser
44
+
45
+
46
+ def main(argv: list[str] | None = None) -> int:
47
+ args = build_parser().parse_args(argv)
48
+ if args.command == "check":
49
+ return _check(args.comment)
50
+ result = evaluate_issue(GitHubClient(), args.issue_url)
51
+ _emit(*present_status(result))
52
+ return 0 if result.state is not None else 2
gtp/github.py ADDED
@@ -0,0 +1,214 @@
1
+ """Read-only GitHub REST adapter for GTP status."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ from typing import Any
9
+ from urllib.error import HTTPError, URLError
10
+ from urllib.parse import quote, urlencode, urlsplit
11
+ from urllib.request import HTTPRedirectHandler, Request, build_opener
12
+
13
+ from .model import Comment
14
+
15
+
16
+ class AcquisitionError(RuntimeError):
17
+ def __init__(self, resource: str, message: str, status: int | None = None):
18
+ super().__init__(message)
19
+ self.resource = resource
20
+ self.status = status
21
+
22
+
23
+ def _origin(url: str) -> tuple[str, str, int | None]:
24
+ parsed = urlsplit(url)
25
+ return parsed.scheme, parsed.hostname or "", parsed.port
26
+
27
+
28
+ def _validate_api_url(url: str) -> None:
29
+ try:
30
+ parsed = urlsplit(url)
31
+ except ValueError as error:
32
+ raise AcquisitionError(url, "invalid GitHub API URL") from error
33
+ if (
34
+ parsed.scheme != "https"
35
+ or parsed.hostname != "api.github.com"
36
+ or parsed.username is not None
37
+ or parsed.password is not None
38
+ or parsed.port is not None
39
+ or parsed.fragment
40
+ ):
41
+ raise AcquisitionError(url, "GitHub API host is not allowed")
42
+
43
+
44
+ class _SafeRedirectHandler(HTTPRedirectHandler):
45
+ def redirect_request(self, request, fp, code, msg, headers, newurl):
46
+ redirected = super().redirect_request(request, fp, code, msg, headers, newurl)
47
+ if redirected is not None and _origin(request.full_url) != _origin(newurl):
48
+ redirected.remove_header("Authorization")
49
+ redirected.unredirected_hdrs.pop("Authorization", None)
50
+ return redirected
51
+
52
+
53
+ _OPENER = build_opener(_SafeRedirectHandler())
54
+
55
+
56
+ def _open(request: Request, timeout: int):
57
+ return _OPENER.open(request, timeout=timeout)
58
+
59
+
60
+ class GitHubClient:
61
+ api_root = "https://api.github.com"
62
+
63
+ def __init__(self, token: str | None = None) -> None:
64
+ self.token = token if token is not None else os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
65
+
66
+ def _request(self, url: str) -> tuple[Any, dict[str, str]]:
67
+ _validate_api_url(url)
68
+ headers = {
69
+ "Accept": "application/vnd.github+json",
70
+ "Cache-Control": "no-cache",
71
+ "User-Agent": "github-task-protocol/1.0",
72
+ "X-GitHub-Api-Version": "2022-11-28",
73
+ }
74
+ if self.token:
75
+ headers["Authorization"] = f"Bearer {self.token}"
76
+ request = Request(url, headers=headers, method="GET")
77
+ try:
78
+ with _open(request, timeout=30) as response:
79
+ final_url = response.geturl()
80
+ if isinstance(final_url, str):
81
+ _validate_api_url(final_url)
82
+ data = json.loads(response.read().decode("utf-8"))
83
+ return data, {key.lower(): value for key, value in response.headers.items()}
84
+ except HTTPError as error:
85
+ raise AcquisitionError(url, f"GitHub API returned HTTP {error.code}", error.code) from error
86
+ except (URLError, TimeoutError, UnicodeError, json.JSONDecodeError) as error:
87
+ raise AcquisitionError(url, str(error)) from error
88
+
89
+ def _get(self, path: str, query: dict[str, str] | None = None) -> Any:
90
+ url = f"{self.api_root}{path}"
91
+ if query:
92
+ url = f"{url}?{urlencode(query)}"
93
+ return self._request(url)[0]
94
+
95
+ def _pages(self, path: str, query: dict[str, str] | None = None) -> list[Any]:
96
+ url = f"{self.api_root}{path}"
97
+ params = dict(query or {})
98
+ params.setdefault("per_page", "100")
99
+ url = f"{url}?{urlencode(params)}"
100
+ values: list[Any] = []
101
+ while url:
102
+ data, headers = self._request(url)
103
+ if not isinstance(data, list):
104
+ raise AcquisitionError(url, "expected a JSON array")
105
+ values.extend(data)
106
+ url = _next_link(headers.get("link", ""))
107
+ return values
108
+
109
+ def repository(self, owner: str, repo: str) -> dict[str, Any]:
110
+ resource = self._get(f"/repos/{quote(owner)}/{quote(repo)}")
111
+ if not isinstance(resource, dict) or not isinstance(resource.get("id"), int):
112
+ raise AcquisitionError(
113
+ f"{self.api_root}/repos/{quote(owner)}/{quote(repo)}",
114
+ "repository identity missing",
115
+ )
116
+ return resource
117
+
118
+ def issue(self, owner: str, repo: str, number: int) -> dict[str, Any]:
119
+ resource = self._get(f"/repos/{quote(owner)}/{quote(repo)}/issues/{number}")
120
+ if (
121
+ not isinstance(resource, dict)
122
+ or not isinstance(resource.get("id"), int)
123
+ or not isinstance(resource.get("created_at"), str)
124
+ or not isinstance(resource.get("updated_at"), str)
125
+ ):
126
+ raise AcquisitionError(
127
+ f"{self.api_root}/repos/{quote(owner)}/{quote(repo)}/issues/{number}",
128
+ "issue snapshot fields missing",
129
+ )
130
+ return resource
131
+
132
+ def comments(self, owner: str, repo: str, number: int) -> list[Comment]:
133
+ data = self._pages(
134
+ f"/repos/{quote(owner)}/{quote(repo)}/issues/{number}/comments",
135
+ {"sort": "created", "direction": "asc"},
136
+ )
137
+ comments: list[Comment] = []
138
+ for item in data:
139
+ if (
140
+ not isinstance(item, dict)
141
+ or not isinstance(item.get("id"), int)
142
+ or not isinstance(item.get("html_url"), str)
143
+ or not isinstance(item.get("created_at"), str)
144
+ or not isinstance(item.get("updated_at"), str)
145
+ or not isinstance(item.get("user"), dict)
146
+ or not isinstance(item["user"].get("login"), str)
147
+ ):
148
+ raise AcquisitionError(
149
+ f"{self.api_root}/repos/{quote(owner)}/{quote(repo)}/issues/{number}/comments",
150
+ "comment snapshot entry is incomplete",
151
+ )
152
+ body = item.get("body")
153
+ if body is not None and not isinstance(body, str):
154
+ raise AcquisitionError(item["html_url"], "comment body is not text")
155
+ comments.append(
156
+ Comment(
157
+ id=item["id"],
158
+ url=item["html_url"],
159
+ body=body or "",
160
+ created_at=item["created_at"],
161
+ updated_at=item["updated_at"],
162
+ github_login=item["user"]["login"],
163
+ )
164
+ )
165
+ return comments
166
+
167
+ def branch(self, owner: str, repo: str, branch: str) -> dict[str, Any] | None:
168
+ try:
169
+ return self._get(f"/repos/{quote(owner)}/{quote(repo)}/branches/{quote(branch, safe='')}")
170
+ except AcquisitionError as error:
171
+ if error.status == 404:
172
+ return None
173
+ raise
174
+
175
+ def pull_requests(self, owner: str, repo: str, branch: str) -> list[dict[str, Any]]:
176
+ resources = self._pages(
177
+ f"/repos/{quote(owner)}/{quote(repo)}/pulls",
178
+ {"state": "all", "head": f"{owner}:{branch}"},
179
+ )
180
+ if not all(isinstance(item, dict) for item in resources):
181
+ raise AcquisitionError(
182
+ f"{self.api_root}/repos/{quote(owner)}/{quote(repo)}/pulls",
183
+ "pull request collection entry is incomplete",
184
+ )
185
+ return resources
186
+
187
+ def pull_request(self, owner: str, repo: str, number: int) -> dict[str, Any]:
188
+ resource = self._get(f"/repos/{quote(owner)}/{quote(repo)}/pulls/{number}")
189
+ if not isinstance(resource, dict):
190
+ raise AcquisitionError(
191
+ f"{self.api_root}/repos/{quote(owner)}/{quote(repo)}/pulls/{number}",
192
+ "pull request snapshot is incomplete",
193
+ )
194
+ return resource
195
+
196
+ def pull_request_files(self, owner: str, repo: str, number: int) -> list[dict[str, Any]]:
197
+ return self._pages(f"/repos/{quote(owner)}/{quote(repo)}/pulls/{number}/files")
198
+
199
+ def check_run(self, owner: str, repo: str, number: int) -> dict[str, Any]:
200
+ return self._get(f"/repos/{quote(owner)}/{quote(repo)}/check-runs/{number}")
201
+
202
+ def artifact(self, owner: str, repo: str, path: str, sha: str) -> dict[str, Any]:
203
+ return self._get(
204
+ f"/repos/{quote(owner)}/{quote(repo)}/contents/{quote(path, safe='/')}",
205
+ {"ref": sha},
206
+ )
207
+
208
+
209
+ def _next_link(header: str) -> str:
210
+ for value in header.split(","):
211
+ match = re.match(r'\s*<([^>]+)>;\s*rel="([^"]+)"', value)
212
+ if match and match.group(2) == "next":
213
+ return match.group(1)
214
+ return ""
gtp/model.py ADDED
@@ -0,0 +1,93 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class Comment:
9
+ id: int
10
+ url: str
11
+ body: str
12
+ created_at: str
13
+ updated_at: str
14
+ github_login: str
15
+
16
+
17
+ @dataclass
18
+ class RecordObservation:
19
+ record: dict[str, Any]
20
+ comment: Comment
21
+ aliases: list[Comment] = field(default_factory=list)
22
+
23
+ @property
24
+ def type(self) -> str:
25
+ return self.record["type"]
26
+
27
+ @property
28
+ def id(self) -> str:
29
+ return self.record["id"]
30
+
31
+ @property
32
+ def alias_urls(self) -> tuple[str, ...]:
33
+ return tuple([self.comment.url, *(item.url for item in self.aliases)])
34
+
35
+ def add_alias(self, comment: Comment) -> None:
36
+ self.aliases.append(comment)
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class Diagnostic:
41
+ token: str
42
+ urls: tuple[str, ...]
43
+ detail: dict[str, Any] = field(default_factory=dict)
44
+
45
+ def projection(self) -> dict[str, Any]:
46
+ result: dict[str, Any] = {"token": self.token, "urls": list(self.urls)}
47
+ if self.detail:
48
+ result["detail"] = self.detail
49
+ return result
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class SuccessorFact:
54
+ url: str
55
+ exists: bool
56
+ repository_id: int | None = None
57
+ issue_id: int | None = None
58
+ created_at: str | None = None
59
+
60
+
61
+ @dataclass
62
+ class FoldContext:
63
+ issue_url: str | None = None
64
+ issue_id: int | None = None
65
+ issue_created_at: str | None = None
66
+ repository_id: int | None = None
67
+ successors: dict[str, SuccessorFact] = field(default_factory=dict)
68
+
69
+
70
+ @dataclass
71
+ class FoldResult:
72
+ recognized_count: int = 0
73
+ recognized_comments: list[Comment] = field(default_factory=list)
74
+ active: dict[str, list[RecordObservation]] = field(
75
+ default_factory=lambda: {kind: [] for kind in ("contract", "start", "done", "stop")}
76
+ )
77
+ observations_by_url: dict[str, RecordObservation] = field(default_factory=dict)
78
+ ids: dict[str, list[RecordObservation]] = field(default_factory=dict)
79
+ diagnostics: list[Diagnostic] = field(default_factory=list)
80
+ started_once: bool = False
81
+ bound_contract: RecordObservation | None = None
82
+ bound_start: RecordObservation | None = None
83
+ terminal_stop: RecordObservation | None = None
84
+
85
+
86
+ class IncompleteSnapshotError(ValueError):
87
+ pass
88
+
89
+
90
+ class ContextAcquisitionRequired(ValueError):
91
+ def __init__(self, resource: str):
92
+ super().__init__(resource)
93
+ self.resource = resource