github-task-protocol 1.0.1__py3-none-any.whl → 1.0.2__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.2.dist-info/METADATA +117 -0
- github_task_protocol-1.0.2.dist-info/RECORD +16 -0
- gtp/__init__.py +1 -1
- gtp/github.py +4 -7
- gtp/presentation.py +36 -12
- gtp/status.py +362 -62
- github_task_protocol-1.0.1.dist-info/METADATA +0 -88
- github_task_protocol-1.0.1.dist-info/RECORD +0 -16
- {github_task_protocol-1.0.1.dist-info → github_task_protocol-1.0.2.dist-info}/WHEEL +0 -0
- {github_task_protocol-1.0.1.dist-info → github_task_protocol-1.0.2.dist-info}/entry_points.txt +0 -0
- {github_task_protocol-1.0.1.dist-info → github_task_protocol-1.0.2.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: github-task-protocol
|
|
3
|
+
Version: 1.0.2
|
|
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
|
+
task固有の未確認事項は、通常のIssue本文やcommentへ人が読める形で残します。開始前から完了判断に必要な不明点はDone Conditionにし、開始後にContract変更が必要になった場合はStopと後継Issueへ移ります。GTP Recordは自由文の意味を自動評価しないため、`status`はDone提示前にIssueを確認するURLを表示します。
|
|
20
|
+
|
|
21
|
+
## 推奨: 明示的にsetupを依頼
|
|
22
|
+
|
|
23
|
+
bare GTP repository URLだけではsetup依頼にもrepository変更のauthorizationにもなりません。URLだけを受け取ったagentは、説明または目的確認に留まり、現在のrepositoryを変更しません。
|
|
24
|
+
|
|
25
|
+
導入先repositoryを操作中のclean agentへ、変更対象とDraft setup PR作成を明示して依頼します。
|
|
26
|
+
|
|
27
|
+
```text
|
|
28
|
+
このrepositoryへGTPを導入するDraft setup PRを作ってください。
|
|
29
|
+
GTP repository: https://github.com/shinya0x00/github-task-protocol
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
この明示依頼を受けたagentは、次の順序でsetupします。
|
|
33
|
+
|
|
34
|
+
agentが手順を理解できることと、実行中ずっと意図の境界内に留まり続けることは別の能力です。この手順はbranch-first順序で境界逸脱のriskを下げます。GTP単独の強制力はこの手順の受入対象にしません。repository ownerは補完策としてGitHub branch protectionまたはrulesetでdefault branchへの直接pushを拒否し、pull request経由の変更を必須にできます。setup agentは保護設定を変更せず、未設定なら人間へ報告します。
|
|
35
|
+
|
|
36
|
+
1. GitHubのlatest stable Releaseを取得し、`draft: false`かつ`prerelease: false`を確認する。未公開candidateやmoving `main`は選ばない。
|
|
37
|
+
2. Releaseのtagをcommit SHAまでdereferenceし、選択したtagとexact commit SHAを記録する。
|
|
38
|
+
3. target fileを変更する前にrepositoryのdefault branch名とhead SHAを記録し、そのheadから`gtp/setup-<tag>-<short-sha>` branchを作ってswitchする。現在branchがdefault branchではなくsetup branchであることを確認できなければ停止する。
|
|
39
|
+
4. そのcommitの`GTP.md`だけを`https://raw.githubusercontent.com/shinya0x00/github-task-protocol/<commit-sha>/GTP.md`から取得し、導入先rootへvendorする。既存`GTP.md`とSHA-256が同一なら保持する。内容が異なるfile、別のGTP authority、または既存instructionとの衝突があれば、上書きせず停止して人間へ報告する。
|
|
40
|
+
5. root `AGENTS.md`がなければ作成する。存在する場合は本文を変更・削除せず、下のexact adapterがなければ`## GitHub Task Protocol adapter` heading付きで追記する。既に同じadapterがあれば重複追加しない。
|
|
41
|
+
6. commitとpushはsetup branchだけに行い、default branchへ直接pushしない。push後にdefault branch headを再取得し、setup開始前に記録したSHAから予期せず変化していたら停止して報告する。Draft setup PRのbodyにはrelease tag、exact commit SHA、immutable `GTP.md` URL、変更file、保持した既存instruction、次に必要な人間判断を書く。
|
|
42
|
+
7. 人間がsetup PRをmergeするまで導入完了としません。merge後、taskごとにGitHub Issueを1件作り、agentへそのIssue URLだけを渡します。
|
|
43
|
+
|
|
44
|
+
共通adapter文:
|
|
45
|
+
|
|
46
|
+
> この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の権限を与えない。
|
|
47
|
+
|
|
48
|
+
配置例は次のとおりです。runtimeごとに異なる指示を作る必要はありません。
|
|
49
|
+
|
|
50
|
+
- Codex: `AGENTS.md`
|
|
51
|
+
- Claude Code: `CLAUDE.md`または`AGENTS.md`
|
|
52
|
+
- Cursor: `AGENTS.md`または`.cursor/rules/gtp.md`
|
|
53
|
+
|
|
54
|
+
## 手動導入
|
|
55
|
+
|
|
56
|
+
自動setupを使わない場合は、次の3手順で導入できます。
|
|
57
|
+
|
|
58
|
+
1. latest stable Releaseをexact commitへ固定し、file変更前にdefault branchからsetup用branchを作ってswitchする。
|
|
59
|
+
2. setup branch上で`GTP.md`をrootへコピーし、共通adapter文を既存instructionへ非破壊で追加する。
|
|
60
|
+
3. commitとpushをsetup branchだけに行ってDraft PRを作り、人間が内容を確認してmergeする。
|
|
61
|
+
|
|
62
|
+
## 4つのRecord
|
|
63
|
+
|
|
64
|
+
| Record | 平易な意味 |
|
|
65
|
+
|---|---|
|
|
66
|
+
| `contract` | 目的、変更してよい範囲、完了条件を固定する |
|
|
67
|
+
| `start` | Contractと唯一の作業branchを結び付ける |
|
|
68
|
+
| `done` | PRのsource headと、条件ごとのEvidenceを提示する |
|
|
69
|
+
| `stop` | 完了を主張せず中止し、必要なら後継Issueを示す |
|
|
70
|
+
|
|
71
|
+
RecordはIssue commentへ人向け要約を先に、機械用JSONを折りたたんで記録します。1 Issue = 1 branch = 1 PRです。
|
|
72
|
+
Start branchへrepositoryのdefault branchは指定できません。
|
|
73
|
+
Startより前から存在するPRは、そのtaskのcandidateやDoneとして引き継げません。誤ったStartをStopする場合は、古いPRを対象外として安全に閉じます。
|
|
74
|
+
|
|
75
|
+
## 6つのstate
|
|
76
|
+
|
|
77
|
+
| state | 平易な意味 |
|
|
78
|
+
|---|---|
|
|
79
|
+
| `unmanaged` | 有効なContractがない |
|
|
80
|
+
| `ready` | ContractはあるがStart前 |
|
|
81
|
+
| `in_progress` | 作業中、またはDone提示後のmerge待ち |
|
|
82
|
+
| `halt` | 特定transitionを矛盾や不適合のため進められない |
|
|
83
|
+
| `done` | Doneのsource headへEvidence resourceが結び付き、そのPRがnative mergeされた。条件内容の十分性は人がEvidenceを読んで判断する |
|
|
84
|
+
| `stopped` | Stopにより、このIssueでの作業を終了した |
|
|
85
|
+
|
|
86
|
+
GitHub情報を完全に取得できない場合はstateを推測しません。404だけではresource不在と権限不足を区別できないため、これは`halt`ではなくAcquisition Errorです。
|
|
87
|
+
|
|
88
|
+
## CLIは任意の検証器
|
|
89
|
+
|
|
90
|
+
人間がGTPを使うためにCLIをinstallする必要はありません。`gtp`はagentや自動検査がRecordと現在stateを確認するための、runtime dependency 0の任意toolです。
|
|
91
|
+
|
|
92
|
+
CLIはPyPI公開後、固定versionを指定して実行できます。現在のsource candidateは`1.0.2`であり、公開確認前です。公開済み`1.0.1`はこのcandidateと挙動が異なるため、下記commandは`1.0.2`の公開Evidence取得後に使用します。GTPを使うだけならCLIのinstallは不要です。
|
|
93
|
+
|
|
94
|
+
```console
|
|
95
|
+
uvx --from github-task-protocol==1.0.2 gtp status <issue-url>
|
|
96
|
+
uvx --from github-task-protocol==1.0.2 gtp check <comment.md>
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
- `status`はGitHubへGETだけを行い、日本語6項目の後にmachine JSONを出します。Evidenceの存在・種類・状態・source headとの結び付きを検査しますが、完了条件の自然言語上の充足までは自動判定しません。
|
|
100
|
+
- `check`は投稿前のMarkdown comment全文をoffline検査します。Issue上でもvalidだとは主張しません。
|
|
101
|
+
- exit code、緑色のCheck Run、Evidence URLは、変更やmergeの許可ではありません。
|
|
102
|
+
|
|
103
|
+
## 仕様と判断記録
|
|
104
|
+
|
|
105
|
+
protocolの唯一の正本は400行以内の[`GTP.md`](GTP.md)です。Record作成やstate判断に、他の文書は必要ありません。
|
|
106
|
+
|
|
107
|
+
[`DECISIONS.md`](DECISIONS.md)は、設計変更の理由と履歴です。`GTP.md`と意味が衝突する場合は`GTP.md`を優先します。
|
|
108
|
+
|
|
109
|
+
実GitHubで観測した引き継ぎ結果は[`acceptance/level0/`](acceptance/level0/)にあります。これは仕様の代わりではありません。
|
|
110
|
+
|
|
111
|
+
## GTPが証明しないこと
|
|
112
|
+
|
|
113
|
+
GTPは、actor本人性、credential安全性、コード品質そのもの、Evidence内容の真実性を証明しません。filesystem削除や本番database操作を物理的に防ぐものでもありません。
|
|
114
|
+
|
|
115
|
+
サンドボックス、最小権限、不可逆操作前の確認、reviewと組み合わせてください。最終的な受理は、人間がPRとEvidenceを読み、GitHubのnative mergeで判断します。
|
|
116
|
+
|
|
117
|
+
License: [MIT](LICENSE)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
gtp/__init__.py,sha256=Mgzlh0-l_XUHLRPtouhrFlM1lX4feMnDUULN0WuIsb0,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=__WJN9-EvzYjVFB56bLAvWsH_alcY6WOQRmYwjwZ6qM,8465
|
|
6
|
+
gtp/model.py,sha256=0CnS7k0bMPn3Bw4voZGT-Jgvez65JX6X_hpCeeyyl4Y,2443
|
|
7
|
+
gtp/presentation.py,sha256=9lfmwcuIo9Ip-CEyrqNtr4Mwh0b6S-HwGjZGbsffRB4,18627
|
|
8
|
+
gtp/reducer.py,sha256=YuEq9ylDfkuYGLEa2YEM9PaUOrhxWY3HnU_p5rMK8G0,6997
|
|
9
|
+
gtp/schema.py,sha256=Jjltq80E8HjaUBPWoQJ_YwRM-Lkm85rCaHuv10sB0Sg,7768
|
|
10
|
+
gtp/status.py,sha256=vJ6J7skWqz1CzBgkYnS1M2ECa0OgSj7GQPLhKiI21c8,32702
|
|
11
|
+
gtp/urls.py,sha256=CxheRZ4ND__RMZx-cejSMqk5tCu6aaoin6g3Q_RK2i8,3332
|
|
12
|
+
github_task_protocol-1.0.2.dist-info/METADATA,sha256=ZLYZol7HPEg54qf7ez1xAoikqxZlqLE8KPVNm7ZTpVU,9005
|
|
13
|
+
github_task_protocol-1.0.2.dist-info/WHEEL,sha256=5CJ4bbElW8cq_GDpNSLjMgfb15S72ImTOLq9c70uj1Q,106
|
|
14
|
+
github_task_protocol-1.0.2.dist-info/entry_points.txt,sha256=ULDQmE66x4_NxwQH1Ru9cEVOJMEUBvT27g2a5FcDrAo,37
|
|
15
|
+
github_task_protocol-1.0.2.dist-info/licenses/LICENSE,sha256=yvK8Xx5r58kF13JNYhXfeRf9IAY9rm-jS3THRU0ttJY,1067
|
|
16
|
+
github_task_protocol-1.0.2.dist-info/RECORD,,
|
gtp/__init__.py
CHANGED
gtp/github.py
CHANGED
|
@@ -164,13 +164,10 @@ class GitHubClient:
|
|
|
164
164
|
)
|
|
165
165
|
return comments
|
|
166
166
|
|
|
167
|
-
def branch(self, owner: str, repo: str, branch: str) -> dict[str, Any]
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
if error.status == 404:
|
|
172
|
-
return None
|
|
173
|
-
raise
|
|
167
|
+
def branch(self, owner: str, repo: str, branch: str) -> dict[str, Any]:
|
|
168
|
+
return self._get(
|
|
169
|
+
f"/repos/{quote(owner)}/{quote(repo)}/branches/{quote(branch, safe='')}"
|
|
170
|
+
)
|
|
174
171
|
|
|
175
172
|
def pull_requests(self, owner: str, repo: str, branch: str) -> list[dict[str, Any]]:
|
|
176
173
|
resources = self._pages(
|
gtp/presentation.py
CHANGED
|
@@ -19,6 +19,9 @@ HALT_MESSAGES = {
|
|
|
19
19
|
"terminal_violation": "terminal stateの前後関係に違反するRecordまたはmergeがあります",
|
|
20
20
|
}
|
|
21
21
|
EVIDENCE_LIMITS = [
|
|
22
|
+
"Check RunがDone Conditionの内容を十分に検査したこと",
|
|
23
|
+
"Artifactの内容がDone Conditionを満たすこと",
|
|
24
|
+
"Issue本文・通常commentに未解決事項がないこと",
|
|
22
25
|
"actor本人性",
|
|
23
26
|
"credential安全性",
|
|
24
27
|
"GitHub外情報を参照しなかったこと",
|
|
@@ -50,6 +53,7 @@ def _content(record: dict[str, Any] | None) -> dict[str, Any]:
|
|
|
50
53
|
|
|
51
54
|
def _task_context(
|
|
52
55
|
*,
|
|
56
|
+
issue_url: str,
|
|
53
57
|
state: str | None,
|
|
54
58
|
acquisition_complete: bool,
|
|
55
59
|
contract: dict[str, Any] | None,
|
|
@@ -94,12 +98,17 @@ def _task_context(
|
|
|
94
98
|
not_proven.extend(missing)
|
|
95
99
|
if done is None:
|
|
96
100
|
not_proven.append("Done Claim未提示")
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
101
|
+
else:
|
|
102
|
+
if state in {"in_progress", "done"} and not missing:
|
|
103
|
+
not_proven.append(
|
|
104
|
+
"Done Conditionの自然言語上の充足は自動判定していない"
|
|
105
|
+
)
|
|
106
|
+
if state == "in_progress":
|
|
107
|
+
not_proven.append("native merge未確認")
|
|
108
|
+
elif state == "halt" and not missing:
|
|
109
|
+
not_proven.append("protocol不適合が未解決")
|
|
110
|
+
elif state == "stopped":
|
|
111
|
+
not_proven.append("このIssueは完了を証明せず停止済み")
|
|
103
112
|
|
|
104
113
|
branch_name = branch.get("name") if isinstance(branch, dict) else None
|
|
105
114
|
if not isinstance(branch_name, str):
|
|
@@ -109,6 +118,8 @@ def _task_context(
|
|
|
109
118
|
return {
|
|
110
119
|
"goal": contract_content.get("goal"),
|
|
111
120
|
"scope": scope if isinstance(scope, list) else [],
|
|
121
|
+
"handoff_url": issue_url,
|
|
122
|
+
"handoff_semantics": "Issue本文・通常commentの意味は自動判定しない",
|
|
112
123
|
"branch": branch_name,
|
|
113
124
|
"pr": pr,
|
|
114
125
|
"conditions": conditions,
|
|
@@ -205,6 +216,7 @@ def status_projection(result: StatusResult) -> dict[str, Any]:
|
|
|
205
216
|
"diagnostics": diagnostics,
|
|
206
217
|
"acquisition_errors": result.acquisition_errors,
|
|
207
218
|
"task_context": _task_context(
|
|
219
|
+
issue_url=result.issue_url,
|
|
208
220
|
state=result.state,
|
|
209
221
|
acquisition_complete=not result.acquisition_errors,
|
|
210
222
|
contract=contract,
|
|
@@ -223,11 +235,17 @@ def _plain_summary(machine: dict[str, Any], context: dict[str, Any]) -> list[str
|
|
|
223
235
|
if state is None
|
|
224
236
|
else "このIssueの完了は確認できません。作業を止めて人が確認してください。"
|
|
225
237
|
if state == "halt"
|
|
226
|
-
else
|
|
238
|
+
else (
|
|
239
|
+
"Done ClaimのEvidence bindingとnative mergeを確認しました。"
|
|
240
|
+
"条件内容の充足はEvidenceを読んで判断してください。"
|
|
241
|
+
)
|
|
227
242
|
if state == "done"
|
|
228
243
|
else "このIssueは完了を主張せず終了しています。"
|
|
229
244
|
if state == "stopped"
|
|
230
|
-
else
|
|
245
|
+
else (
|
|
246
|
+
"Done ClaimのEvidence bindingを確認しましたが、マージはまだです。"
|
|
247
|
+
"条件内容の充足はEvidenceを読んで判断してください。"
|
|
248
|
+
)
|
|
231
249
|
if machine["next_action"] == "await_merge"
|
|
232
250
|
else "このIssueはまだ作業途中です。"
|
|
233
251
|
)
|
|
@@ -254,6 +272,9 @@ def _plain_summary(machine: dict[str, Any], context: dict[str, Any]) -> list[str
|
|
|
254
272
|
f" 変更してよい場所: {scope_text}",
|
|
255
273
|
f" 作業場所: branch {context.get('branch') or '未確認'}"
|
|
256
274
|
f" / PR {context.get('pr') or '未確認'}",
|
|
275
|
+
" 未確認事項の確認先: "
|
|
276
|
+
f"{context.get('handoff_url') or machine['issue_url']}"
|
|
277
|
+
"(Issue本文・通常commentの意味は自動判定していません)",
|
|
257
278
|
]
|
|
258
279
|
)
|
|
259
280
|
|
|
@@ -274,7 +295,7 @@ def _plain_summary(machine: dict[str, Any], context: dict[str, Any]) -> list[str
|
|
|
274
295
|
evidence_confirmed = state == "done" or machine["next_action"] == "await_merge"
|
|
275
296
|
if presented:
|
|
276
297
|
lines.append(
|
|
277
|
-
"
|
|
298
|
+
" Evidence bindingを確認した条件(条件内容の充足は自動判定していません):"
|
|
278
299
|
if evidence_confirmed
|
|
279
300
|
else " 記録に確認資料へのリンクがある条件(達成済みとはまだ断定しません):"
|
|
280
301
|
)
|
|
@@ -321,7 +342,10 @@ def _status_text(machine: dict[str, Any]) -> list[str]:
|
|
|
321
342
|
"post_contract": "目的・変更範囲・完了条件をContractとして記録してください",
|
|
322
343
|
"post_start": "Contractと作業branchをStartで束縛してください",
|
|
323
344
|
"continue_work": "束縛されたbranchで作業を続けてください",
|
|
324
|
-
"post_done":
|
|
345
|
+
"post_done": (
|
|
346
|
+
"Issue本文・通常commentの未確認事項と完了条件を確認し、"
|
|
347
|
+
"source headとEvidenceをDoneで提示してください"
|
|
348
|
+
),
|
|
325
349
|
"await_merge": "Done ClaimとEvidenceを確認し、人間のnative merge判断を待ってください",
|
|
326
350
|
"inspect_halt": "最初のURLを確認し、記録を推測で補わず人間へ戻してください",
|
|
327
351
|
"none_done": "追加のprotocol actionはありません",
|
|
@@ -336,7 +360,7 @@ def _status_text(machine: dict[str, Any]) -> list[str]:
|
|
|
336
360
|
token = machine["halt_reason"]
|
|
337
361
|
reason = f"{token} — {HALT_MESSAGES.get(token, 'protocol上の矛盾または不適合を確認しました')}"
|
|
338
362
|
elif action == "await_merge":
|
|
339
|
-
reason = "Done Claimは確認済みですが、native mergeはまだ確認できません"
|
|
363
|
+
reason = "Done ClaimのEvidence bindingは確認済みですが、native mergeはまだ確認できません"
|
|
340
364
|
elif action == "post_done":
|
|
341
365
|
reason = f"Start、作業branch {branch_name or '(名前不明)'}、PR candidateを確認しました"
|
|
342
366
|
elif action == "continue_work":
|
|
@@ -345,7 +369,7 @@ def _status_text(machine: dict[str, Any]) -> list[str]:
|
|
|
345
369
|
reason = {
|
|
346
370
|
"unmanaged": "有効なContractがありません",
|
|
347
371
|
"ready": "有効なContractがあり、Startはまだありません",
|
|
348
|
-
"done": "Done Claimのsource head、Evidence、native mergeを確認しました",
|
|
372
|
+
"done": "Done Claimのsource head、Evidence binding、native mergeを確認しました",
|
|
349
373
|
"stopped": "有効なStop Recordを確認しました",
|
|
350
374
|
}[machine["state"]]
|
|
351
375
|
lines = [
|
gtp/status.py
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import datetime, timezone
|
|
6
7
|
from typing import Any
|
|
7
8
|
from urllib.parse import unquote
|
|
8
9
|
|
|
@@ -14,7 +15,7 @@ from .model import (
|
|
|
14
15
|
RecordObservation,
|
|
15
16
|
SuccessorFact,
|
|
16
17
|
)
|
|
17
|
-
from .reducer import fold_comments, historical_state
|
|
18
|
+
from .reducer import fold_comments, historical_state
|
|
18
19
|
from .urls import parse_github_url
|
|
19
20
|
|
|
20
21
|
|
|
@@ -96,6 +97,72 @@ def _pr_matches(pr: dict[str, Any], repo_id: int, branch: str) -> bool:
|
|
|
96
97
|
)
|
|
97
98
|
|
|
98
99
|
|
|
100
|
+
def _branch_snapshot_key(
|
|
101
|
+
branch: dict[str, Any] | None,
|
|
102
|
+
resource: str,
|
|
103
|
+
) -> tuple[str, str] | None:
|
|
104
|
+
if branch is None:
|
|
105
|
+
return None
|
|
106
|
+
name = branch.get("name")
|
|
107
|
+
sha = branch.get("commit", {}).get("sha")
|
|
108
|
+
if not isinstance(name, str) or not isinstance(sha, str):
|
|
109
|
+
raise AcquisitionError(resource, "branch snapshot fields missing")
|
|
110
|
+
return name, sha
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _pr_snapshot_key(pr: dict[str, Any], resource: str) -> tuple[Any, ...]:
|
|
114
|
+
number = pr.get("number")
|
|
115
|
+
url = pr.get("html_url")
|
|
116
|
+
base = pr.get("base", {})
|
|
117
|
+
base_repo_id = base.get("repo", {}).get("id")
|
|
118
|
+
base_ref = base.get("ref")
|
|
119
|
+
base_sha = base.get("sha")
|
|
120
|
+
head = pr.get("head", {})
|
|
121
|
+
head_repo_id = head.get("repo", {}).get("id")
|
|
122
|
+
head_ref = head.get("ref")
|
|
123
|
+
head_sha = head.get("sha")
|
|
124
|
+
merged_at = pr.get("merged_at")
|
|
125
|
+
state = pr.get("state")
|
|
126
|
+
changed_files = pr.get("changed_files")
|
|
127
|
+
if (
|
|
128
|
+
not isinstance(number, int)
|
|
129
|
+
or not isinstance(url, str)
|
|
130
|
+
or not isinstance(base_repo_id, int)
|
|
131
|
+
or not isinstance(base_ref, str)
|
|
132
|
+
or not isinstance(base_sha, str)
|
|
133
|
+
or not isinstance(head_repo_id, int)
|
|
134
|
+
or not isinstance(head_ref, str)
|
|
135
|
+
or not isinstance(head_sha, str)
|
|
136
|
+
or not isinstance(state, str)
|
|
137
|
+
or (merged_at is not None and not isinstance(merged_at, str))
|
|
138
|
+
or (
|
|
139
|
+
changed_files is not None
|
|
140
|
+
and (not isinstance(changed_files, int) or changed_files < 0)
|
|
141
|
+
)
|
|
142
|
+
):
|
|
143
|
+
raise AcquisitionError(resource, "pull request snapshot fields missing")
|
|
144
|
+
return (
|
|
145
|
+
number,
|
|
146
|
+
url,
|
|
147
|
+
base_repo_id,
|
|
148
|
+
base_ref,
|
|
149
|
+
base_sha,
|
|
150
|
+
head_repo_id,
|
|
151
|
+
head_ref,
|
|
152
|
+
head_sha,
|
|
153
|
+
state,
|
|
154
|
+
merged_at,
|
|
155
|
+
changed_files,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _pr_collection_snapshot(
|
|
160
|
+
prs: list[dict[str, Any]],
|
|
161
|
+
resource: str,
|
|
162
|
+
) -> tuple[tuple[Any, ...], ...]:
|
|
163
|
+
return tuple(sorted(_pr_snapshot_key(pr, resource) for pr in prs))
|
|
164
|
+
|
|
165
|
+
|
|
99
166
|
def _path_in_scope(path: str, scope: list[str]) -> bool:
|
|
100
167
|
for entry in scope:
|
|
101
168
|
if entry == "." or path == entry or (entry.endswith("/") and path.startswith(entry)):
|
|
@@ -115,7 +182,22 @@ def _scope_diagnostics(
|
|
|
115
182
|
pr_url = pr.get("html_url")
|
|
116
183
|
if not isinstance(number, int) or not isinstance(pr_url, str):
|
|
117
184
|
raise AcquisitionError(anchor_url, "pull request identity missing")
|
|
185
|
+
listed_key = _pr_snapshot_key(pr, pr_url)
|
|
186
|
+
detail = client.pull_request(owner, repo, number)
|
|
187
|
+
detail_key = _pr_snapshot_key(detail, pr_url)
|
|
188
|
+
if listed_key[:-1] != detail_key[:-1] or (
|
|
189
|
+
listed_key[-1] is not None and listed_key[-1] != detail_key[-1]
|
|
190
|
+
):
|
|
191
|
+
raise AcquisitionError(pr_url, "pull request changed during acquisition")
|
|
192
|
+
changed_files = detail_key[-1]
|
|
193
|
+
if not isinstance(changed_files, int) or changed_files < 0:
|
|
194
|
+
raise AcquisitionError(pr_url, "pull request changed_files missing")
|
|
118
195
|
files = client.pull_request_files(owner, repo, number)
|
|
196
|
+
detail_after = client.pull_request(owner, repo, number)
|
|
197
|
+
if _pr_snapshot_key(detail_after, pr_url) != detail_key:
|
|
198
|
+
raise AcquisitionError(pr_url, "pull request changed during file acquisition")
|
|
199
|
+
if len(files) != changed_files:
|
|
200
|
+
raise AcquisitionError(pr_url, "pull request file collection is incomplete")
|
|
119
201
|
observed_paths: list[str] = []
|
|
120
202
|
for item in files:
|
|
121
203
|
if not isinstance(item, dict) or not isinstance(item.get("filename"), str):
|
|
@@ -134,26 +216,42 @@ def _scope_diagnostics(
|
|
|
134
216
|
return []
|
|
135
217
|
|
|
136
218
|
|
|
137
|
-
def
|
|
138
|
-
|
|
219
|
+
def _github_time(value: object, resource: str, field: str) -> datetime:
|
|
220
|
+
if not isinstance(value, str):
|
|
221
|
+
raise AcquisitionError(resource, f"{field} missing")
|
|
222
|
+
try:
|
|
223
|
+
observed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
224
|
+
except ValueError as error:
|
|
225
|
+
raise AcquisitionError(resource, f"{field} invalid") from error
|
|
226
|
+
if observed.tzinfo is None:
|
|
227
|
+
raise AcquisitionError(resource, f"{field} timezone missing")
|
|
228
|
+
return observed.astimezone(timezone.utc)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _pr_not_after_start(
|
|
232
|
+
pr: dict[str, Any],
|
|
233
|
+
start: RecordObservation,
|
|
234
|
+
resource: str,
|
|
235
|
+
) -> bool:
|
|
236
|
+
created_time = _github_time(
|
|
237
|
+
pr.get("created_at"), resource, "pull request created_at"
|
|
238
|
+
)
|
|
239
|
+
start_time = _github_time(
|
|
240
|
+
start.comment.created_at, start.comment.url, "Start created_at"
|
|
241
|
+
)
|
|
242
|
+
return created_time <= start_time
|
|
139
243
|
|
|
140
244
|
|
|
141
245
|
def _successor_context(
|
|
142
246
|
client: GitHubClient,
|
|
143
|
-
|
|
247
|
+
refs: list[str],
|
|
144
248
|
) -> dict[str, SuccessorFact]:
|
|
145
249
|
facts: dict[str, SuccessorFact] = {}
|
|
146
|
-
for url in
|
|
250
|
+
for url in refs:
|
|
147
251
|
parsed = parse_github_url(url, "issue")
|
|
148
252
|
assert parsed is not None
|
|
149
253
|
repository = client.repository(parsed.owner, parsed.repo)
|
|
150
|
-
|
|
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
|
|
254
|
+
issue = client.issue(parsed.owner, parsed.repo, parsed.number or 0)
|
|
157
255
|
facts[url] = SuccessorFact(
|
|
158
256
|
url,
|
|
159
257
|
"pull_request" not in issue,
|
|
@@ -189,28 +287,16 @@ def _live_evidence(
|
|
|
189
287
|
if parsed.sha != head_sha:
|
|
190
288
|
diagnostics.append(_diagnostic("stale_evidence", done.comment.url, url))
|
|
191
289
|
continue
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
|
290
|
+
resource = client.artifact(
|
|
291
|
+
parsed.owner,
|
|
292
|
+
parsed.repo,
|
|
293
|
+
unquote(parsed.path or "", encoding="utf-8", errors="strict"),
|
|
294
|
+
head_sha,
|
|
295
|
+
)
|
|
204
296
|
if not isinstance(resource, dict) or resource.get("type") != "file":
|
|
205
297
|
diagnostics.append(_diagnostic("invalid_evidence", done.comment.url, url))
|
|
206
298
|
else:
|
|
207
|
-
|
|
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
|
|
299
|
+
resource = client.check_run(parsed.owner, parsed.repo, parsed.number or 0)
|
|
214
300
|
if resource.get("head_sha") != head_sha:
|
|
215
301
|
diagnostics.append(_diagnostic("stale_evidence", done.comment.url, url))
|
|
216
302
|
elif resource.get("status") != "completed":
|
|
@@ -228,6 +314,7 @@ def _evaluate_done(
|
|
|
228
314
|
client: GitHubClient,
|
|
229
315
|
repo_id: int,
|
|
230
316
|
branch_name: str,
|
|
317
|
+
start: RecordObservation,
|
|
231
318
|
contract: RecordObservation,
|
|
232
319
|
done: RecordObservation,
|
|
233
320
|
) -> _DoneLive:
|
|
@@ -235,17 +322,13 @@ def _evaluate_done(
|
|
|
235
322
|
pr_url = parse_github_url(done.record["pr_ref"], "pr")
|
|
236
323
|
assert pr_url is not None
|
|
237
324
|
pr_repo = client.repository(pr_url.owner, pr_url.repo)
|
|
238
|
-
|
|
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
|
|
325
|
+
pr = client.pull_request(pr_url.owner, pr_url.repo, pr_url.number or 0)
|
|
247
326
|
result.pr = pr
|
|
248
|
-
if
|
|
327
|
+
if (
|
|
328
|
+
not _repo_matches(pr_repo, repo_id)
|
|
329
|
+
or not _pr_matches(pr, repo_id, branch_name)
|
|
330
|
+
or _pr_not_after_start(pr, start, done.record["pr_ref"])
|
|
331
|
+
):
|
|
249
332
|
result.diagnostics.append(
|
|
250
333
|
_diagnostic("invalid_binding", done.comment.url, done.record["pr_ref"])
|
|
251
334
|
)
|
|
@@ -276,7 +359,9 @@ def _evaluate_done(
|
|
|
276
359
|
client, repo_id, contract, done
|
|
277
360
|
)
|
|
278
361
|
pr_after = client.pull_request(pr_url.owner, pr_url.repo, pr_url.number or 0)
|
|
279
|
-
if pr_after
|
|
362
|
+
if _pr_snapshot_key(pr_after, done.record["pr_ref"]) != _pr_snapshot_key(
|
|
363
|
+
pr, done.record["pr_ref"]
|
|
364
|
+
):
|
|
280
365
|
raise AcquisitionError(done.record["pr_ref"], "bound pull request head changed during acquisition")
|
|
281
366
|
result.pr = pr_after
|
|
282
367
|
if scope_diagnostics:
|
|
@@ -299,12 +384,20 @@ def _terminal_violations(
|
|
|
299
384
|
diagnostics: list[Diagnostic],
|
|
300
385
|
recognized_comments: list[Any],
|
|
301
386
|
terminal_at: str,
|
|
302
|
-
|
|
387
|
+
safe_retry_urls: set[str],
|
|
303
388
|
) -> list[Diagnostic]:
|
|
304
|
-
|
|
305
|
-
|
|
389
|
+
violating_urls = {
|
|
390
|
+
comment.url
|
|
391
|
+
for comment in recognized_comments
|
|
392
|
+
if comment.created_at > terminal_at and comment.url not in safe_retry_urls
|
|
393
|
+
}
|
|
394
|
+
result = [
|
|
395
|
+
diagnostic
|
|
396
|
+
for diagnostic in diagnostics
|
|
397
|
+
if not any(url in violating_urls for url in diagnostic.urls)
|
|
398
|
+
]
|
|
306
399
|
for comment in recognized_comments:
|
|
307
|
-
if comment.
|
|
400
|
+
if comment.url in violating_urls:
|
|
308
401
|
item = _diagnostic("terminal_violation", comment.url)
|
|
309
402
|
if item not in result:
|
|
310
403
|
result.append(item)
|
|
@@ -338,16 +431,65 @@ def _stop_diagnostics(
|
|
|
338
431
|
if start is None:
|
|
339
432
|
return diagnostics
|
|
340
433
|
branch_name = start.record["branch"]
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
434
|
+
resource = f"https://api.github.com/repos/{owner}/{repo}/pulls"
|
|
435
|
+
candidates = client.pull_requests(owner, repo, branch_name)
|
|
436
|
+
candidates_after = client.pull_requests(owner, repo, branch_name)
|
|
437
|
+
if _pr_collection_snapshot(candidates, resource) != _pr_collection_snapshot(
|
|
438
|
+
candidates_after, resource
|
|
439
|
+
):
|
|
440
|
+
raise AcquisitionError(resource, "pull request collection changed during acquisition")
|
|
441
|
+
for pr in candidates_after:
|
|
442
|
+
if not _pr_matches(pr, repo_id, branch_name):
|
|
443
|
+
continue
|
|
444
|
+
pr_url = pr.get("html_url")
|
|
445
|
+
if not isinstance(pr_url, str):
|
|
446
|
+
raise AcquisitionError(stop.comment.url, "pull request ordering fields missing")
|
|
447
|
+
created_time = _github_time(
|
|
448
|
+
pr.get("created_at"), pr_url, "pull request created_at"
|
|
449
|
+
)
|
|
450
|
+
stop_time = _github_time(
|
|
451
|
+
stop.comment.created_at, stop.comment.url, "Stop created_at"
|
|
452
|
+
)
|
|
453
|
+
if created_time == stop_time:
|
|
454
|
+
raise AcquisitionError(
|
|
455
|
+
pr_url, "pull request creation and Stop ordering is ambiguous"
|
|
456
|
+
)
|
|
457
|
+
if _pr_not_after_start(pr, start, pr_url):
|
|
458
|
+
continue
|
|
459
|
+
if created_time > stop_time:
|
|
460
|
+
continue
|
|
461
|
+
merged_at = pr.get("merged_at")
|
|
462
|
+
if not isinstance(merged_at, str):
|
|
463
|
+
continue
|
|
464
|
+
merged_time = _github_time(merged_at, pr_url, "pull request merged_at")
|
|
465
|
+
if merged_time == stop_time:
|
|
466
|
+
raise AcquisitionError(pr_url, "merge and Stop ordering is ambiguous")
|
|
467
|
+
if merged_time > stop_time:
|
|
468
|
+
diagnostics.append(
|
|
469
|
+
_diagnostic("terminal_violation", stop.comment.url, pr_url)
|
|
470
|
+
)
|
|
348
471
|
return diagnostics
|
|
349
472
|
|
|
350
473
|
|
|
474
|
+
def _has_terminal_violation(diagnostics: list[Diagnostic]) -> bool:
|
|
475
|
+
return any(item.token == "terminal_violation" for item in diagnostics)
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _done_can_be_evaluated(
|
|
479
|
+
contract: RecordObservation,
|
|
480
|
+
done: RecordObservation,
|
|
481
|
+
) -> bool:
|
|
482
|
+
expected = contract.record["done_conditions"]
|
|
483
|
+
actual = done.record["evidence"]
|
|
484
|
+
if set(expected) != set(actual):
|
|
485
|
+
return False
|
|
486
|
+
return all(
|
|
487
|
+
parse_github_url(actual[condition_id], condition["evidence_kind"])
|
|
488
|
+
is not None
|
|
489
|
+
for condition_id, condition in expected.items()
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
|
|
351
493
|
def _evaluate_acquired(
|
|
352
494
|
client: GitHubClient,
|
|
353
495
|
issue_url: str,
|
|
@@ -380,9 +522,16 @@ def _evaluate_acquired(
|
|
|
380
522
|
issue_id=issue.get("id"),
|
|
381
523
|
issue_created_at=issue.get("created_at"),
|
|
382
524
|
repository_id=repo_id,
|
|
383
|
-
successors=_successor_context(client, comments),
|
|
384
525
|
)
|
|
385
526
|
fold = fold_comments(comments, context)
|
|
527
|
+
if fold.terminal_stop is not None and not _has_terminal_violation(
|
|
528
|
+
fold.diagnostics
|
|
529
|
+
):
|
|
530
|
+
stop_record = fold.terminal_stop.record
|
|
531
|
+
if stop_record["reason"] == "superseded":
|
|
532
|
+
context.successors = _successor_context(
|
|
533
|
+
client, [stop_record["successor_ref"]]
|
|
534
|
+
)
|
|
386
535
|
except IncompleteSnapshotError as error:
|
|
387
536
|
return _acquisition(issue_url, AcquisitionError(issue_url, str(error)))
|
|
388
537
|
except AcquisitionError as error:
|
|
@@ -392,6 +541,7 @@ def _evaluate_acquired(
|
|
|
392
541
|
fold.active["contract"][0] if len(fold.active["contract"]) == 1 else None
|
|
393
542
|
)
|
|
394
543
|
active_done = fold.active["done"][0] if len(fold.active["done"]) == 1 else None
|
|
544
|
+
terminal_done = fold.active["done"][0] if fold.active["done"] else None
|
|
395
545
|
current: dict[str, Any] = {
|
|
396
546
|
"contract": _record_projection(active_contract),
|
|
397
547
|
"start": _record_projection(fold.bound_start),
|
|
@@ -403,6 +553,8 @@ def _evaluate_acquired(
|
|
|
403
553
|
return StatusResult(issue_url, base_state, list(fold.diagnostics), current)
|
|
404
554
|
if fold.bound_start is None or fold.bound_contract is None:
|
|
405
555
|
if fold.terminal_stop is not None:
|
|
556
|
+
if _has_terminal_violation(fold.diagnostics):
|
|
557
|
+
return StatusResult(issue_url, "halt", list(fold.diagnostics), current)
|
|
406
558
|
try:
|
|
407
559
|
stop_diagnostics = _stop_diagnostics(
|
|
408
560
|
client,
|
|
@@ -425,7 +577,41 @@ def _evaluate_acquired(
|
|
|
425
577
|
branch_name = fold.bound_start.record["branch"]
|
|
426
578
|
current["branch"] = {"name": branch_name}
|
|
427
579
|
if fold.terminal_stop is not None:
|
|
580
|
+
if _has_terminal_violation(fold.diagnostics):
|
|
581
|
+
return StatusResult(issue_url, "halt", list(fold.diagnostics), current)
|
|
428
582
|
try:
|
|
583
|
+
if terminal_done is not None and _done_can_be_evaluated(
|
|
584
|
+
fold.bound_contract, terminal_done
|
|
585
|
+
):
|
|
586
|
+
live_done = _evaluate_done(
|
|
587
|
+
client,
|
|
588
|
+
repo_id,
|
|
589
|
+
branch_name,
|
|
590
|
+
fold.bound_start,
|
|
591
|
+
fold.bound_contract,
|
|
592
|
+
terminal_done,
|
|
593
|
+
)
|
|
594
|
+
if live_done.terminal_at == fold.terminal_stop.comment.created_at:
|
|
595
|
+
raise AcquisitionError(
|
|
596
|
+
terminal_done.record["pr_ref"],
|
|
597
|
+
"Done terminal and Stop ordering is ambiguous",
|
|
598
|
+
)
|
|
599
|
+
if (
|
|
600
|
+
live_done.terminal_at is not None
|
|
601
|
+
and live_done.terminal_at < fold.terminal_stop.comment.created_at
|
|
602
|
+
):
|
|
603
|
+
return StatusResult(
|
|
604
|
+
issue_url,
|
|
605
|
+
"halt",
|
|
606
|
+
[
|
|
607
|
+
_diagnostic(
|
|
608
|
+
"terminal_violation",
|
|
609
|
+
fold.terminal_stop.comment.url,
|
|
610
|
+
terminal_done.record["pr_ref"],
|
|
611
|
+
)
|
|
612
|
+
],
|
|
613
|
+
current,
|
|
614
|
+
)
|
|
429
615
|
stop_diagnostics = _stop_diagnostics(
|
|
430
616
|
client, parsed.owner, parsed.repo, repo_id, fold.terminal_stop, fold.bound_start, context
|
|
431
617
|
)
|
|
@@ -435,25 +621,88 @@ def _evaluate_acquired(
|
|
|
435
621
|
return StatusResult(issue_url, "halt", [*fold.diagnostics, *stop_diagnostics], current)
|
|
436
622
|
return StatusResult(issue_url, "stopped", list(fold.diagnostics), current)
|
|
437
623
|
|
|
438
|
-
|
|
439
|
-
|
|
624
|
+
default_branch = repository.get("default_branch")
|
|
625
|
+
if not isinstance(default_branch, str):
|
|
626
|
+
return _acquisition(
|
|
627
|
+
issue_url,
|
|
628
|
+
AcquisitionError(issue_url, "repository default_branch missing"),
|
|
629
|
+
)
|
|
630
|
+
if branch_name == default_branch:
|
|
631
|
+
return StatusResult(
|
|
632
|
+
issue_url,
|
|
633
|
+
"halt",
|
|
634
|
+
[_diagnostic("invalid_binding", fold.bound_start.comment.url)],
|
|
635
|
+
current,
|
|
636
|
+
)
|
|
440
637
|
|
|
441
638
|
try:
|
|
442
|
-
if
|
|
443
|
-
|
|
639
|
+
if terminal_done is not None and _done_can_be_evaluated(
|
|
640
|
+
fold.bound_contract, terminal_done
|
|
641
|
+
):
|
|
642
|
+
live = _evaluate_done(
|
|
643
|
+
client,
|
|
644
|
+
repo_id,
|
|
645
|
+
branch_name,
|
|
646
|
+
fold.bound_start,
|
|
647
|
+
fold.bound_contract,
|
|
648
|
+
terminal_done,
|
|
649
|
+
)
|
|
444
650
|
if live.pr is not None:
|
|
445
651
|
current["bound_pr"] = live.pr.get("html_url")
|
|
446
652
|
current["bound_pr_head_sha"] = live.pr.get("head", {}).get("sha")
|
|
447
653
|
if live.diagnostics:
|
|
448
|
-
return StatusResult(
|
|
654
|
+
return StatusResult(
|
|
655
|
+
issue_url,
|
|
656
|
+
"halt",
|
|
657
|
+
[*fold.diagnostics, *live.diagnostics],
|
|
658
|
+
current,
|
|
659
|
+
)
|
|
449
660
|
if live.terminal_at is not None:
|
|
661
|
+
safe_retry_urls = {
|
|
662
|
+
url
|
|
663
|
+
for observations in fold.ids.values()
|
|
664
|
+
for observation in observations
|
|
665
|
+
if len(observation.alias_urls) > 1
|
|
666
|
+
for url in observation.alias_urls
|
|
667
|
+
}
|
|
450
668
|
diagnostics = _terminal_violations(
|
|
451
|
-
fold.diagnostics,
|
|
669
|
+
fold.diagnostics,
|
|
670
|
+
fold.recognized_comments,
|
|
671
|
+
live.terminal_at,
|
|
672
|
+
safe_retry_urls,
|
|
452
673
|
)
|
|
453
|
-
return StatusResult(
|
|
674
|
+
return StatusResult(
|
|
675
|
+
issue_url,
|
|
676
|
+
"halt" if diagnostics else "done",
|
|
677
|
+
diagnostics,
|
|
678
|
+
current,
|
|
679
|
+
)
|
|
680
|
+
if fold.diagnostics:
|
|
681
|
+
return StatusResult(issue_url, "halt", list(fold.diagnostics), current)
|
|
454
682
|
if live.pr and live.pr.get("merged_at"):
|
|
455
683
|
return StatusResult(issue_url, "in_progress", [], current)
|
|
456
684
|
branch = client.branch(parsed.owner, parsed.repo, branch_name)
|
|
685
|
+
branch_after = client.branch(parsed.owner, parsed.repo, branch_name)
|
|
686
|
+
branch_resource = (
|
|
687
|
+
f"https://api.github.com/repos/{parsed.owner}/{parsed.repo}/branches/"
|
|
688
|
+
f"{branch_name}"
|
|
689
|
+
)
|
|
690
|
+
if _branch_snapshot_key(branch, branch_resource) != _branch_snapshot_key(
|
|
691
|
+
branch_after, branch_resource
|
|
692
|
+
):
|
|
693
|
+
raise AcquisitionError(
|
|
694
|
+
branch_resource, "bound branch changed during acquisition"
|
|
695
|
+
)
|
|
696
|
+
branch_key = _branch_snapshot_key(branch_after, branch_resource)
|
|
697
|
+
pr_head = live.pr.get("head", {}).get("sha") if live.pr else None
|
|
698
|
+
if (
|
|
699
|
+
branch_key is None
|
|
700
|
+
or branch_key[1] != pr_head
|
|
701
|
+
or pr_head != terminal_done.record["head_sha"]
|
|
702
|
+
):
|
|
703
|
+
raise AcquisitionError(
|
|
704
|
+
branch_resource, "bound branch and pull request head disagree"
|
|
705
|
+
)
|
|
457
706
|
current["branch"]["exists"] = branch is not None
|
|
458
707
|
if branch is None:
|
|
459
708
|
return StatusResult(
|
|
@@ -464,10 +713,16 @@ def _evaluate_acquired(
|
|
|
464
713
|
)
|
|
465
714
|
return StatusResult(issue_url, "in_progress", [], current)
|
|
466
715
|
|
|
716
|
+
if fold.diagnostics:
|
|
717
|
+
return StatusResult(issue_url, "halt", list(fold.diagnostics), current)
|
|
718
|
+
|
|
467
719
|
branch = client.branch(parsed.owner, parsed.repo, branch_name)
|
|
468
720
|
observed_candidates = client.pull_requests(parsed.owner, parsed.repo, branch_name)
|
|
469
721
|
invalid_candidates = [
|
|
470
|
-
pr
|
|
722
|
+
pr
|
|
723
|
+
for pr in observed_candidates
|
|
724
|
+
if not _pr_matches(pr, repo_id, branch_name)
|
|
725
|
+
or _pr_not_after_start(pr, fold.bound_start, pr.get("html_url", issue_url))
|
|
471
726
|
]
|
|
472
727
|
if invalid_candidates:
|
|
473
728
|
urls = [fold.bound_start.comment.url] + [
|
|
@@ -503,6 +758,23 @@ def _evaluate_acquired(
|
|
|
503
758
|
)
|
|
504
759
|
if scope_diagnostics:
|
|
505
760
|
return StatusResult(issue_url, "halt", scope_diagnostics, current)
|
|
761
|
+
branch_after = client.branch(parsed.owner, parsed.repo, branch_name)
|
|
762
|
+
candidates_after = client.pull_requests(parsed.owner, parsed.repo, branch_name)
|
|
763
|
+
branch_resource = (
|
|
764
|
+
f"https://api.github.com/repos/{parsed.owner}/{parsed.repo}/branches/"
|
|
765
|
+
f"{branch_name}"
|
|
766
|
+
)
|
|
767
|
+
pulls_resource = (
|
|
768
|
+
f"https://api.github.com/repos/{parsed.owner}/{parsed.repo}/pulls"
|
|
769
|
+
)
|
|
770
|
+
if _branch_snapshot_key(branch, branch_resource) != _branch_snapshot_key(
|
|
771
|
+
branch_after, branch_resource
|
|
772
|
+
) or _pr_collection_snapshot(
|
|
773
|
+
observed_candidates, pulls_resource
|
|
774
|
+
) != _pr_collection_snapshot(candidates_after, pulls_resource):
|
|
775
|
+
raise AcquisitionError(
|
|
776
|
+
issue_url, "branch or pull request collection changed during acquisition"
|
|
777
|
+
)
|
|
506
778
|
if branch is None:
|
|
507
779
|
return StatusResult(
|
|
508
780
|
issue_url,
|
|
@@ -524,6 +796,21 @@ def _issue_snapshot_key(issue: dict[str, Any]) -> tuple[int, str, str]:
|
|
|
524
796
|
return issue_id, created_at, updated_at
|
|
525
797
|
|
|
526
798
|
|
|
799
|
+
def _repository_snapshot_key(
|
|
800
|
+
repository: dict[str, Any], resource: str
|
|
801
|
+
) -> tuple[int, str, str]:
|
|
802
|
+
repo_id = repository.get("id")
|
|
803
|
+
full_name = repository.get("full_name")
|
|
804
|
+
default_branch = repository.get("default_branch")
|
|
805
|
+
if (
|
|
806
|
+
not isinstance(repo_id, int)
|
|
807
|
+
or not isinstance(full_name, str)
|
|
808
|
+
or not isinstance(default_branch, str)
|
|
809
|
+
):
|
|
810
|
+
raise AcquisitionError(resource, "repository snapshot fields missing")
|
|
811
|
+
return repo_id, full_name, default_branch
|
|
812
|
+
|
|
813
|
+
|
|
527
814
|
def evaluate_issue(client: GitHubClient, issue_url: str) -> StatusResult:
|
|
528
815
|
parsed = parse_github_url(issue_url, "issue")
|
|
529
816
|
if parsed is None:
|
|
@@ -534,6 +821,12 @@ def evaluate_issue(client: GitHubClient, issue_url: str) -> StatusResult:
|
|
|
534
821
|
)
|
|
535
822
|
try:
|
|
536
823
|
repository = client.repository(parsed.owner, parsed.repo)
|
|
824
|
+
repository_resource = (
|
|
825
|
+
f"https://api.github.com/repos/{parsed.owner}/{parsed.repo}"
|
|
826
|
+
)
|
|
827
|
+
repository_key = _repository_snapshot_key(
|
|
828
|
+
repository, repository_resource
|
|
829
|
+
)
|
|
537
830
|
issue = client.issue(parsed.owner, parsed.repo, parsed.number or 0)
|
|
538
831
|
initial_key = _issue_snapshot_key(issue)
|
|
539
832
|
comments = client.comments(parsed.owner, parsed.repo, parsed.number or 0)
|
|
@@ -544,6 +837,13 @@ def evaluate_issue(client: GitHubClient, issue_url: str) -> StatusResult:
|
|
|
544
837
|
if result.state is None:
|
|
545
838
|
return result
|
|
546
839
|
try:
|
|
840
|
+
repository_after = client.repository(parsed.owner, parsed.repo)
|
|
841
|
+
if _repository_snapshot_key(
|
|
842
|
+
repository_after, repository_resource
|
|
843
|
+
) != repository_key:
|
|
844
|
+
raise AcquisitionError(
|
|
845
|
+
repository_resource, "repository snapshot changed during acquisition"
|
|
846
|
+
)
|
|
547
847
|
issue_after = client.issue(parsed.owner, parsed.repo, parsed.number or 0)
|
|
548
848
|
if _issue_snapshot_key(issue_after) != initial_key:
|
|
549
849
|
raise AcquisitionError(issue_url, "issue snapshot changed during acquisition")
|
|
@@ -1,88 +0,0 @@
|
|
|
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)
|
|
@@ -1,16 +0,0 @@
|
|
|
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,,
|
|
File without changes
|
{github_task_protocol-1.0.1.dist-info → github_task_protocol-1.0.2.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
{github_task_protocol-1.0.1.dist-info → github_task_protocol-1.0.2.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|