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
|
@@ -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,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
gtp/__main__.py
ADDED
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
|