github-task-protocol 1.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- github_task_protocol-1.0.1.dist-info/METADATA +88 -0
- github_task_protocol-1.0.1.dist-info/RECORD +16 -0
- github_task_protocol-1.0.1.dist-info/WHEEL +5 -0
- github_task_protocol-1.0.1.dist-info/entry_points.txt +2 -0
- github_task_protocol-1.0.1.dist-info/licenses/LICENSE +21 -0
- gtp/__init__.py +3 -0
- gtp/__main__.py +4 -0
- gtp/carrier.py +107 -0
- gtp/cli.py +52 -0
- gtp/github.py +214 -0
- gtp/model.py +93 -0
- gtp/presentation.py +419 -0
- gtp/reducer.py +184 -0
- gtp/schema.py +211 -0
- gtp/status.py +552 -0
- gtp/urls.py +100 -0
gtp/presentation.py
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
"""Plain-first Japanese and machine-exact CLI projections."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .carrier import CarrierResult
|
|
8
|
+
from .status import StatusResult
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
AUTHORITY_NOTICE = "この出力は変更・完了・mergeの許可を与えません"
|
|
12
|
+
HALT_MESSAGES = {
|
|
13
|
+
"invalid_record": "GTP Recordの形式、内容、編集状態のいずれかが不正です",
|
|
14
|
+
"conflicting_records": "同じ役割のRecordが競合しています",
|
|
15
|
+
"invalid_transition": "Recordの順序または参照関係が成立していません",
|
|
16
|
+
"invalid_binding": "Issue、branch、PR、scopeの束縛が一致しません",
|
|
17
|
+
"invalid_evidence": "Done Conditionに対応するEvidenceを確認できません",
|
|
18
|
+
"stale_evidence": "EvidenceがDoneのsource head SHAと一致しません",
|
|
19
|
+
"terminal_violation": "terminal stateの前後関係に違反するRecordまたはmergeがあります",
|
|
20
|
+
}
|
|
21
|
+
EVIDENCE_LIMITS = [
|
|
22
|
+
"actor本人性",
|
|
23
|
+
"credential安全性",
|
|
24
|
+
"GitHub外情報を参照しなかったこと",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _record(value: dict[str, Any] | None) -> dict[str, Any] | None:
|
|
29
|
+
if value is None:
|
|
30
|
+
return None
|
|
31
|
+
observation = {
|
|
32
|
+
key: value[key]
|
|
33
|
+
for key in ("url", "aliases", "comment_id")
|
|
34
|
+
if key in value
|
|
35
|
+
}
|
|
36
|
+
result = {
|
|
37
|
+
"type": value.get("type"),
|
|
38
|
+
"id": value.get("id"),
|
|
39
|
+
"observation": observation,
|
|
40
|
+
}
|
|
41
|
+
if isinstance(value.get("content"), dict):
|
|
42
|
+
result["content"] = value["content"]
|
|
43
|
+
return result
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _content(record: dict[str, Any] | None) -> dict[str, Any]:
|
|
47
|
+
value = record.get("content") if isinstance(record, dict) else None
|
|
48
|
+
return value if isinstance(value, dict) else {}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _task_context(
|
|
52
|
+
*,
|
|
53
|
+
state: str | None,
|
|
54
|
+
acquisition_complete: bool,
|
|
55
|
+
contract: dict[str, Any] | None,
|
|
56
|
+
start: dict[str, Any] | None,
|
|
57
|
+
done: dict[str, Any] | None,
|
|
58
|
+
branch: dict[str, Any] | None,
|
|
59
|
+
pr: str | None,
|
|
60
|
+
) -> dict[str, Any]:
|
|
61
|
+
contract_content = _content(contract)
|
|
62
|
+
start_content = _content(start)
|
|
63
|
+
done_content = _content(done)
|
|
64
|
+
done_conditions = contract_content.get("done_conditions")
|
|
65
|
+
evidence = done_content.get("evidence")
|
|
66
|
+
conditions: dict[str, dict[str, Any]] = {}
|
|
67
|
+
if isinstance(done_conditions, dict):
|
|
68
|
+
evidence_map = evidence if isinstance(evidence, dict) else {}
|
|
69
|
+
for condition_id in sorted(done_conditions):
|
|
70
|
+
condition = done_conditions[condition_id]
|
|
71
|
+
if not isinstance(condition, dict):
|
|
72
|
+
continue
|
|
73
|
+
evidence_url = evidence_map.get(condition_id)
|
|
74
|
+
conditions[condition_id] = {
|
|
75
|
+
"text": condition.get("text"),
|
|
76
|
+
"evidence_kind": condition.get("evidence_kind"),
|
|
77
|
+
"evidence_url": evidence_url if isinstance(evidence_url, str) else None,
|
|
78
|
+
"evidence_status": (
|
|
79
|
+
"presented" if isinstance(evidence_url, str) else "not_presented"
|
|
80
|
+
),
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
not_proven: list[str] = []
|
|
84
|
+
if not acquisition_complete:
|
|
85
|
+
not_proven.append("GitHub情報の取得が不完全なためtask context未確認")
|
|
86
|
+
elif not contract_content:
|
|
87
|
+
not_proven.append("Contract未確認")
|
|
88
|
+
else:
|
|
89
|
+
missing = [
|
|
90
|
+
f"{condition_id}: Evidence未提示"
|
|
91
|
+
for condition_id, condition in conditions.items()
|
|
92
|
+
if condition["evidence_status"] == "not_presented"
|
|
93
|
+
]
|
|
94
|
+
not_proven.extend(missing)
|
|
95
|
+
if done is None:
|
|
96
|
+
not_proven.append("Done Claim未提示")
|
|
97
|
+
elif state == "in_progress":
|
|
98
|
+
not_proven.append("native merge未確認")
|
|
99
|
+
elif state == "halt" and not missing:
|
|
100
|
+
not_proven.append("protocol不適合が未解決")
|
|
101
|
+
elif state == "stopped":
|
|
102
|
+
not_proven.append("このIssueは完了を証明せず停止済み")
|
|
103
|
+
|
|
104
|
+
branch_name = branch.get("name") if isinstance(branch, dict) else None
|
|
105
|
+
if not isinstance(branch_name, str):
|
|
106
|
+
candidate = start_content.get("branch")
|
|
107
|
+
branch_name = candidate if isinstance(candidate, str) else None
|
|
108
|
+
scope = contract_content.get("scope")
|
|
109
|
+
return {
|
|
110
|
+
"goal": contract_content.get("goal"),
|
|
111
|
+
"scope": scope if isinstance(scope, list) else [],
|
|
112
|
+
"branch": branch_name,
|
|
113
|
+
"pr": pr,
|
|
114
|
+
"conditions": conditions,
|
|
115
|
+
"not_proven": not_proven,
|
|
116
|
+
"evidence_limits": list(EVIDENCE_LIMITS),
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _branch(value: dict[str, Any] | None) -> dict[str, Any] | None:
|
|
121
|
+
if value is None:
|
|
122
|
+
return None
|
|
123
|
+
observation = {"exists": value["exists"]} if "exists" in value else {}
|
|
124
|
+
return {"name": value.get("name"), "observation": observation}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _next_action(result: StatusResult) -> str:
|
|
128
|
+
if result.state is None:
|
|
129
|
+
return "retry_acquisition"
|
|
130
|
+
if result.state == "unmanaged":
|
|
131
|
+
return "post_contract"
|
|
132
|
+
if result.state == "ready":
|
|
133
|
+
return "post_start"
|
|
134
|
+
if result.state == "halt":
|
|
135
|
+
return "inspect_halt"
|
|
136
|
+
if result.state == "done":
|
|
137
|
+
return "none_done"
|
|
138
|
+
if result.state == "stopped":
|
|
139
|
+
return "none_stopped"
|
|
140
|
+
if result.current.get("done") is not None:
|
|
141
|
+
return "await_merge"
|
|
142
|
+
if result.current.get("bound_pr") or result.current.get("pr_candidates"):
|
|
143
|
+
return "post_done"
|
|
144
|
+
return "continue_work"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _primary_url(result: StatusResult) -> str:
|
|
148
|
+
if result.acquisition_errors:
|
|
149
|
+
resource = result.acquisition_errors[0].get("resource")
|
|
150
|
+
return resource if isinstance(resource, str) else result.issue_url
|
|
151
|
+
if result.diagnostics and result.diagnostics[0].urls:
|
|
152
|
+
return result.diagnostics[0].urls[0]
|
|
153
|
+
current = result.current
|
|
154
|
+
if isinstance(current.get("bound_pr"), str):
|
|
155
|
+
return current["bound_pr"]
|
|
156
|
+
candidates = current.get("pr_candidates")
|
|
157
|
+
if isinstance(candidates, list) and candidates and isinstance(candidates[0], str):
|
|
158
|
+
return candidates[0]
|
|
159
|
+
for kind in ("stop", "done", "start", "contract"):
|
|
160
|
+
value = current.get(kind)
|
|
161
|
+
if isinstance(value, dict) and isinstance(value.get("url"), str):
|
|
162
|
+
return value["url"]
|
|
163
|
+
return result.issue_url
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def status_projection(result: StatusResult) -> dict[str, Any]:
|
|
167
|
+
diagnostics = [item.projection() for item in result.diagnostics]
|
|
168
|
+
current = result.current
|
|
169
|
+
candidates = current.get("pr_candidates")
|
|
170
|
+
candidate = candidates[0] if isinstance(candidates, list) and len(candidates) == 1 else None
|
|
171
|
+
contract = _record(current.get("contract"))
|
|
172
|
+
start = _record(current.get("start"))
|
|
173
|
+
done = _record(current.get("done"))
|
|
174
|
+
stop = _record(current.get("stop"))
|
|
175
|
+
branch = _branch(current.get("branch"))
|
|
176
|
+
bound_pr = current.get("bound_pr")
|
|
177
|
+
done_pr = _content(done).get("pr_ref")
|
|
178
|
+
pr = (
|
|
179
|
+
bound_pr
|
|
180
|
+
if isinstance(bound_pr, str)
|
|
181
|
+
else candidate
|
|
182
|
+
if isinstance(candidate, str)
|
|
183
|
+
else done_pr
|
|
184
|
+
if isinstance(done_pr, str)
|
|
185
|
+
else None
|
|
186
|
+
)
|
|
187
|
+
return {
|
|
188
|
+
"gtp": "1.0",
|
|
189
|
+
"command": "status",
|
|
190
|
+
"issue_url": result.issue_url,
|
|
191
|
+
"state": result.state,
|
|
192
|
+
"halt_reason": diagnostics[0]["token"] if result.state == "halt" and diagnostics else None,
|
|
193
|
+
"details": [item["detail"] for item in diagnostics if "detail" in item],
|
|
194
|
+
"next_action": _next_action(result),
|
|
195
|
+
"primary_url": _primary_url(result),
|
|
196
|
+
"authority": "none",
|
|
197
|
+
"acquisition": "incomplete" if result.acquisition_errors else "complete",
|
|
198
|
+
"contract": contract,
|
|
199
|
+
"start": start,
|
|
200
|
+
"done": done,
|
|
201
|
+
"stop": stop,
|
|
202
|
+
"branch": branch,
|
|
203
|
+
"pr_candidate": candidate,
|
|
204
|
+
"bound_pr": bound_pr,
|
|
205
|
+
"diagnostics": diagnostics,
|
|
206
|
+
"acquisition_errors": result.acquisition_errors,
|
|
207
|
+
"task_context": _task_context(
|
|
208
|
+
state=result.state,
|
|
209
|
+
acquisition_complete=not result.acquisition_errors,
|
|
210
|
+
contract=contract,
|
|
211
|
+
start=start,
|
|
212
|
+
done=done,
|
|
213
|
+
branch=branch,
|
|
214
|
+
pr=pr,
|
|
215
|
+
),
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _plain_summary(machine: dict[str, Any], context: dict[str, Any]) -> list[str]:
|
|
220
|
+
state = machine["state"]
|
|
221
|
+
conclusion = (
|
|
222
|
+
"GitHub情報を最後まで取得できていないため、まだ判断できません。"
|
|
223
|
+
if state is None
|
|
224
|
+
else "このIssueの完了は確認できません。作業を止めて人が確認してください。"
|
|
225
|
+
if state == "halt"
|
|
226
|
+
else "このIssueの完了を確認しました。"
|
|
227
|
+
if state == "done"
|
|
228
|
+
else "このIssueは完了を主張せず終了しています。"
|
|
229
|
+
if state == "stopped"
|
|
230
|
+
else "完了条件の確認資料はそろっていますが、マージはまだです。"
|
|
231
|
+
if machine["next_action"] == "await_merge"
|
|
232
|
+
else "このIssueはまだ作業途中です。"
|
|
233
|
+
)
|
|
234
|
+
lines = ["かんたんな説明:", f" 結論: {conclusion}"]
|
|
235
|
+
if state is None:
|
|
236
|
+
lines.extend(
|
|
237
|
+
[
|
|
238
|
+
f" 次にすること: GitHub情報をもう一度取得してください。最初の確認先: {machine['primary_url']}",
|
|
239
|
+
" 大事な点: 情報を取得できないことと、記録に矛盾があることは別です。",
|
|
240
|
+
" ここまでが人向けの説明です。続くJSONは機械処理用です。",
|
|
241
|
+
]
|
|
242
|
+
)
|
|
243
|
+
return lines
|
|
244
|
+
if state == "halt":
|
|
245
|
+
lines.append(f" 次にすること: 原因の記録を開いてください: {machine['primary_url']}")
|
|
246
|
+
|
|
247
|
+
goal = context.get("goal")
|
|
248
|
+
if isinstance(goal, str):
|
|
249
|
+
scope = context.get("scope")
|
|
250
|
+
scope_text = "、".join(scope) if isinstance(scope, list) and scope else "記録なし"
|
|
251
|
+
lines.extend(
|
|
252
|
+
[
|
|
253
|
+
f" この作業の目的: {goal}",
|
|
254
|
+
f" 変更してよい場所: {scope_text}",
|
|
255
|
+
f" 作業場所: branch {context.get('branch') or '未確認'}"
|
|
256
|
+
f" / PR {context.get('pr') or '未確認'}",
|
|
257
|
+
]
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
conditions = context.get("conditions")
|
|
261
|
+
if not isinstance(conditions, dict) or not conditions:
|
|
262
|
+
lines.append(" ここまでが人向けの説明です。続くJSONは機械処理用です。")
|
|
263
|
+
return lines
|
|
264
|
+
presented = [
|
|
265
|
+
(condition_id, condition)
|
|
266
|
+
for condition_id, condition in conditions.items()
|
|
267
|
+
if condition.get("evidence_status") == "presented"
|
|
268
|
+
]
|
|
269
|
+
missing = [
|
|
270
|
+
(condition_id, condition)
|
|
271
|
+
for condition_id, condition in conditions.items()
|
|
272
|
+
if condition.get("evidence_status") == "not_presented"
|
|
273
|
+
]
|
|
274
|
+
evidence_confirmed = state == "done" or machine["next_action"] == "await_merge"
|
|
275
|
+
if presented:
|
|
276
|
+
lines.append(
|
|
277
|
+
" 確認できた完了条件:"
|
|
278
|
+
if evidence_confirmed
|
|
279
|
+
else " 記録に確認資料へのリンクがある条件(達成済みとはまだ断定しません):"
|
|
280
|
+
)
|
|
281
|
+
for condition_id, condition in presented:
|
|
282
|
+
lines.extend(
|
|
283
|
+
[
|
|
284
|
+
f" - {condition.get('text') or '説明未記録'}(識別子: {condition_id})",
|
|
285
|
+
f" 確認資料: {condition.get('evidence_url')}",
|
|
286
|
+
]
|
|
287
|
+
)
|
|
288
|
+
if missing:
|
|
289
|
+
lines.append(" 確認資料が足りない条件:")
|
|
290
|
+
for condition_id, condition in missing:
|
|
291
|
+
lines.extend(
|
|
292
|
+
[
|
|
293
|
+
f" - {condition.get('text') or '説明未記録'}(識別子: {condition_id})",
|
|
294
|
+
" 不足しているもの: 条件を確認するための証拠リンク",
|
|
295
|
+
]
|
|
296
|
+
)
|
|
297
|
+
if state == "halt":
|
|
298
|
+
lines.append(
|
|
299
|
+
" 大事な点: 足りない記録を推測で補わないでください。"
|
|
300
|
+
"この表示だけでは変更・完了・mergeできません。"
|
|
301
|
+
)
|
|
302
|
+
lines.append(" ここまでが人向けの説明です。続くJSONは機械処理用です。")
|
|
303
|
+
return lines
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _status_text(machine: dict[str, Any]) -> list[str]:
|
|
307
|
+
state = machine["state"] if machine["state"] is not None else "不明"
|
|
308
|
+
action = machine["next_action"]
|
|
309
|
+
branch = machine.get("branch")
|
|
310
|
+
branch_name = branch.get("name") if isinstance(branch, dict) else None
|
|
311
|
+
stop_text = {
|
|
312
|
+
"unmanaged": "停止は不要です。Contractを記録する段階です",
|
|
313
|
+
"ready": "停止は不要です。作業開始前です",
|
|
314
|
+
"in_progress": "停止は不要です。作業中です",
|
|
315
|
+
"halt": "このtransitionは進められません",
|
|
316
|
+
"done": "停止は不要です。完了状態です",
|
|
317
|
+
"stopped": "このIssueでは作業を再開しません",
|
|
318
|
+
None: "状態を決められないため、このtransitionは進められません",
|
|
319
|
+
}[machine["state"]]
|
|
320
|
+
action_text = {
|
|
321
|
+
"post_contract": "目的・変更範囲・完了条件をContractとして記録してください",
|
|
322
|
+
"post_start": "Contractと作業branchをStartで束縛してください",
|
|
323
|
+
"continue_work": "束縛されたbranchで作業を続けてください",
|
|
324
|
+
"post_done": "完了条件を確認し、source headとEvidenceをDoneで提示してください",
|
|
325
|
+
"await_merge": "Done ClaimとEvidenceを確認し、人間のnative merge判断を待ってください",
|
|
326
|
+
"inspect_halt": "最初のURLを確認し、記録を推測で補わず人間へ戻してください",
|
|
327
|
+
"none_done": "追加のprotocol actionはありません",
|
|
328
|
+
"none_stopped": "必要なら新しいIssueでContractから開始してください",
|
|
329
|
+
"retry_acquisition": "GitHub情報を再取得してください",
|
|
330
|
+
}[action]
|
|
331
|
+
if machine["state"] is None:
|
|
332
|
+
errors = machine["acquisition_errors"]
|
|
333
|
+
code = errors[0].get("code", "acquisition_incomplete") if errors else "acquisition_incomplete"
|
|
334
|
+
reason = f"{code} — GitHub情報を完全に取得できず、stateを決定していません"
|
|
335
|
+
elif machine["state"] == "halt":
|
|
336
|
+
token = machine["halt_reason"]
|
|
337
|
+
reason = f"{token} — {HALT_MESSAGES.get(token, 'protocol上の矛盾または不適合を確認しました')}"
|
|
338
|
+
elif action == "await_merge":
|
|
339
|
+
reason = "Done Claimは確認済みですが、native mergeはまだ確認できません"
|
|
340
|
+
elif action == "post_done":
|
|
341
|
+
reason = f"Start、作業branch {branch_name or '(名前不明)'}、PR candidateを確認しました"
|
|
342
|
+
elif action == "continue_work":
|
|
343
|
+
reason = f"Startと作業branch {branch_name or '(名前不明)'}を確認しました"
|
|
344
|
+
else:
|
|
345
|
+
reason = {
|
|
346
|
+
"unmanaged": "有効なContractがありません",
|
|
347
|
+
"ready": "有効なContractがあり、Startはまだありません",
|
|
348
|
+
"done": "Done Claimのsource head、Evidence、native mergeを確認しました",
|
|
349
|
+
"stopped": "有効なStop Recordを確認しました",
|
|
350
|
+
}[machine["state"]]
|
|
351
|
+
lines = [
|
|
352
|
+
f"状態: {state}",
|
|
353
|
+
f"停止要否: {stop_text}",
|
|
354
|
+
f"次の行動: {action_text}",
|
|
355
|
+
f"理由: {reason}",
|
|
356
|
+
f"最初のURL: {machine['primary_url']}",
|
|
357
|
+
f"非許可表示: {AUTHORITY_NOTICE}",
|
|
358
|
+
]
|
|
359
|
+
context = machine["task_context"]
|
|
360
|
+
lines.extend(_plain_summary(machine, context))
|
|
361
|
+
goal = context.get("goal")
|
|
362
|
+
if not isinstance(goal, str):
|
|
363
|
+
return lines
|
|
364
|
+
return lines
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def present_status(result: StatusResult) -> tuple[list[str], dict[str, Any]]:
|
|
368
|
+
machine = status_projection(result)
|
|
369
|
+
return _status_text(machine), machine
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def check_projection(result: CarrierResult) -> dict[str, Any]:
|
|
373
|
+
record = None
|
|
374
|
+
if result.record is not None:
|
|
375
|
+
record = {"type": result.record["type"], "id": result.record["id"]}
|
|
376
|
+
return {
|
|
377
|
+
"gtp": "1.0",
|
|
378
|
+
"command": "check",
|
|
379
|
+
"recognized": result.recognized,
|
|
380
|
+
"schema_valid": result.schema_valid,
|
|
381
|
+
"contextual_checks": "not_run",
|
|
382
|
+
"projected_state": None,
|
|
383
|
+
"record": record,
|
|
384
|
+
"errors": result.errors,
|
|
385
|
+
"authority": "none",
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def present_check(result: CarrierResult) -> tuple[list[str], dict[str, Any]]:
|
|
390
|
+
machine = check_projection(result)
|
|
391
|
+
if not result.recognized:
|
|
392
|
+
summary = "通常commentであり、GTP Carrierではありません"
|
|
393
|
+
elif result.schema_valid:
|
|
394
|
+
summary = "GTP Carrierとして認識し、offline schemaに適合しました"
|
|
395
|
+
else:
|
|
396
|
+
summary = "GTP Carrierとして認識しましたが、形式・JSON・schemaに適合しません"
|
|
397
|
+
return [
|
|
398
|
+
f"検査結果: {summary}",
|
|
399
|
+
"contextual checks: Issue上の参照関係とstateは検査していません",
|
|
400
|
+
f"非許可表示: {AUTHORITY_NOTICE}",
|
|
401
|
+
], machine
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def present_input_error(message: str) -> tuple[list[str], dict[str, Any]]:
|
|
405
|
+
return [
|
|
406
|
+
"検査結果: 入力ファイルをUTF-8のMarkdown commentとして読めません",
|
|
407
|
+
"contextual checks: 実行していません",
|
|
408
|
+
f"非許可表示: {AUTHORITY_NOTICE}",
|
|
409
|
+
], {
|
|
410
|
+
"gtp": "1.0",
|
|
411
|
+
"command": "check",
|
|
412
|
+
"recognized": None,
|
|
413
|
+
"schema_valid": None,
|
|
414
|
+
"contextual_checks": "not_run",
|
|
415
|
+
"projected_state": None,
|
|
416
|
+
"record": None,
|
|
417
|
+
"errors": [{"code": "input_error", "message": message}],
|
|
418
|
+
"authority": "none",
|
|
419
|
+
}
|
gtp/reducer.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Pure Server Order fold for GTP v1 Records."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .carrier import classify_carrier
|
|
6
|
+
from .model import Comment, Diagnostic, FoldContext, FoldResult, IncompleteSnapshotError, RecordObservation
|
|
7
|
+
from .urls import parse_github_url
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
HALT_REASONS = frozenset(
|
|
11
|
+
{
|
|
12
|
+
"invalid_record",
|
|
13
|
+
"conflicting_records",
|
|
14
|
+
"invalid_transition",
|
|
15
|
+
"invalid_binding",
|
|
16
|
+
"invalid_evidence",
|
|
17
|
+
"stale_evidence",
|
|
18
|
+
"terminal_violation",
|
|
19
|
+
}
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _diagnose(result: FoldResult, token: str, *urls: str) -> None:
|
|
24
|
+
diagnostic = Diagnostic(token, tuple(dict.fromkeys(urls)))
|
|
25
|
+
if diagnostic not in result.diagnostics:
|
|
26
|
+
result.diagnostics.append(diagnostic)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _remove_observation(result: FoldResult, target: RecordObservation) -> None:
|
|
30
|
+
result.active[target.type] = [item for item in result.active[target.type] if item is not target]
|
|
31
|
+
if result.bound_contract is target:
|
|
32
|
+
result.bound_contract = None
|
|
33
|
+
if result.bound_start is target:
|
|
34
|
+
result.bound_start = None
|
|
35
|
+
result.started_once = False
|
|
36
|
+
if result.terminal_stop is target:
|
|
37
|
+
result.terminal_stop = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _accept_context(result: FoldResult, observation: RecordObservation) -> None:
|
|
41
|
+
record_type = observation.type
|
|
42
|
+
url = observation.comment.url
|
|
43
|
+
|
|
44
|
+
if record_type == "contract":
|
|
45
|
+
result.active[record_type].append(observation)
|
|
46
|
+
if result.started_once:
|
|
47
|
+
_diagnose(result, "invalid_transition", url)
|
|
48
|
+
elif len(result.active[record_type]) > 1:
|
|
49
|
+
_diagnose(
|
|
50
|
+
result,
|
|
51
|
+
"conflicting_records",
|
|
52
|
+
*(item.comment.url for item in result.active[record_type]),
|
|
53
|
+
)
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
if record_type == "start":
|
|
57
|
+
result.active[record_type].append(observation)
|
|
58
|
+
if len(result.active[record_type]) > 1:
|
|
59
|
+
_diagnose(
|
|
60
|
+
result,
|
|
61
|
+
"conflicting_records",
|
|
62
|
+
*(item.comment.url for item in result.active[record_type]),
|
|
63
|
+
)
|
|
64
|
+
return
|
|
65
|
+
contracts = result.active["contract"]
|
|
66
|
+
if len(contracts) != 1:
|
|
67
|
+
_diagnose(result, "invalid_transition", url, *(item.comment.url for item in contracts))
|
|
68
|
+
return
|
|
69
|
+
contract = contracts[0]
|
|
70
|
+
if observation.record["contract_ref"] not in contract.alias_urls:
|
|
71
|
+
_diagnose(result, "invalid_binding", url, observation.record["contract_ref"])
|
|
72
|
+
return
|
|
73
|
+
result.started_once = True
|
|
74
|
+
result.bound_contract = contract
|
|
75
|
+
result.bound_start = observation
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
if record_type == "done":
|
|
79
|
+
result.active[record_type].append(observation)
|
|
80
|
+
if len(result.active[record_type]) > 1:
|
|
81
|
+
_diagnose(
|
|
82
|
+
result,
|
|
83
|
+
"conflicting_records",
|
|
84
|
+
*(item.comment.url for item in result.active[record_type]),
|
|
85
|
+
)
|
|
86
|
+
return
|
|
87
|
+
if not result.started_once or result.bound_contract is None:
|
|
88
|
+
_diagnose(result, "invalid_transition", url)
|
|
89
|
+
return
|
|
90
|
+
expected = result.bound_contract.record["done_conditions"]
|
|
91
|
+
actual = observation.record["evidence"]
|
|
92
|
+
if set(expected) != set(actual):
|
|
93
|
+
_diagnose(result, "invalid_evidence", url)
|
|
94
|
+
return
|
|
95
|
+
for condition_id, condition in expected.items():
|
|
96
|
+
if parse_github_url(actual[condition_id], condition["evidence_kind"]) is None:
|
|
97
|
+
_diagnose(result, "invalid_evidence", url, actual[condition_id])
|
|
98
|
+
return
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
if record_type == "stop":
|
|
102
|
+
result.active[record_type].append(observation)
|
|
103
|
+
result.terminal_stop = observation
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def successor_refs(comments: list[Comment]) -> list[str]:
|
|
107
|
+
refs: list[str] = []
|
|
108
|
+
for comment in comments:
|
|
109
|
+
carrier = classify_carrier(comment.body)
|
|
110
|
+
if comment.updated_at != comment.created_at or not carrier.schema_valid or carrier.record is None:
|
|
111
|
+
continue
|
|
112
|
+
record = carrier.record
|
|
113
|
+
if record["type"] == "stop" and record["reason"] == "superseded":
|
|
114
|
+
refs.append(record["successor_ref"])
|
|
115
|
+
return list(dict.fromkeys(refs))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def fold_comments(comments: list[Comment], context: FoldContext | None = None) -> FoldResult:
|
|
119
|
+
"""Fold a complete, strictly ordered issue-comment snapshot without live I/O."""
|
|
120
|
+
|
|
121
|
+
del context # Live successor facts belong to the status adapter, not the pure fold.
|
|
122
|
+
ids = [comment.id for comment in comments]
|
|
123
|
+
if ids != sorted(ids) or len(ids) != len(set(ids)):
|
|
124
|
+
raise IncompleteSnapshotError("comment IDs must be strictly ascending and unique")
|
|
125
|
+
|
|
126
|
+
result = FoldResult()
|
|
127
|
+
for comment in comments:
|
|
128
|
+
carrier = classify_carrier(comment.body)
|
|
129
|
+
if not carrier.recognized:
|
|
130
|
+
continue
|
|
131
|
+
result.recognized_count += 1
|
|
132
|
+
result.recognized_comments.append(comment)
|
|
133
|
+
|
|
134
|
+
if comment.updated_at != comment.created_at or not carrier.schema_valid or carrier.record is None:
|
|
135
|
+
if result.terminal_stop is not None:
|
|
136
|
+
_diagnose(result, "terminal_violation", comment.url)
|
|
137
|
+
else:
|
|
138
|
+
_diagnose(result, "invalid_record", comment.url)
|
|
139
|
+
continue
|
|
140
|
+
|
|
141
|
+
observation = RecordObservation(carrier.record, comment)
|
|
142
|
+
same_id = result.ids.get(observation.id, [])
|
|
143
|
+
identical = next((item for item in same_id if item.record == observation.record), None)
|
|
144
|
+
if identical is not None:
|
|
145
|
+
identical.add_alias(comment)
|
|
146
|
+
result.observations_by_url[comment.url] = identical
|
|
147
|
+
continue
|
|
148
|
+
|
|
149
|
+
if result.terminal_stop is not None:
|
|
150
|
+
result.ids.setdefault(observation.id, []).append(observation)
|
|
151
|
+
result.observations_by_url[comment.url] = observation
|
|
152
|
+
_diagnose(result, "terminal_violation", comment.url)
|
|
153
|
+
continue
|
|
154
|
+
|
|
155
|
+
if same_id:
|
|
156
|
+
members = [url for item in same_id for url in item.alias_urls] + [comment.url]
|
|
157
|
+
for item in same_id:
|
|
158
|
+
_remove_observation(result, item)
|
|
159
|
+
result.ids[observation.id].append(observation)
|
|
160
|
+
result.observations_by_url[comment.url] = observation
|
|
161
|
+
_diagnose(result, "invalid_record", *members)
|
|
162
|
+
continue
|
|
163
|
+
|
|
164
|
+
result.ids.setdefault(observation.id, []).append(observation)
|
|
165
|
+
result.observations_by_url[comment.url] = observation
|
|
166
|
+
_accept_context(result, observation)
|
|
167
|
+
|
|
168
|
+
return result
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def historical_state(result: FoldResult) -> str:
|
|
172
|
+
if result.recognized_count == 0:
|
|
173
|
+
return "unmanaged"
|
|
174
|
+
if result.terminal_stop is not None:
|
|
175
|
+
if any(item.token == "terminal_violation" for item in result.diagnostics):
|
|
176
|
+
return "halt"
|
|
177
|
+
return "stopped"
|
|
178
|
+
if result.diagnostics:
|
|
179
|
+
return "halt"
|
|
180
|
+
if not result.started_once and len(result.active["contract"]) == 1:
|
|
181
|
+
return "ready"
|
|
182
|
+
if result.started_once:
|
|
183
|
+
return "in_progress"
|
|
184
|
+
return "halt"
|