[verified] Add ordered initial rework review input gate
This commit is contained in:
@@ -62,6 +62,8 @@ Bootstrap 이후 이 Issue 재작업도 canonical [Repository cache 운영 지
|
||||
|
||||
## 입력 승인 gate
|
||||
|
||||
초기 review decision gate는 [references/input-gate.md](references/input-gate.md)의 public `input-gate`를 **no_op 또는 source edit 전에 반드시 먼저** 호출한다. 이 gate는 선택된 source review나 renderer `expected.json`을 입력으로 요구하지 않는다. Receipt의 `rework` ID를 읽은 뒤 finding은 수동으로 검증하며 automatic `COMMENT` classification은 지원하지 않는다. Gate의 `no_op`은 구현 재작업을 건너뛴다는 뜻일 뿐 native review publisher의 idempotent review reuse/no-op과 다르다. Native publication, journals, lease/worktree admission, full Discovery approval semantics와 pre-write freshness는 이 receipt로 증명되지 않으며 독립적으로 수행한다.
|
||||
|
||||
Repository file을 수정하기 전에 다음을 모두 검증한다.
|
||||
|
||||
- `owner`, `repository`, Issue 번호와 PR 번호가 canonical identifier다.
|
||||
@@ -77,7 +79,7 @@ Repository file을 수정하기 전에 다음을 모두 검증한다.
|
||||
- Changed-file, review, review-comment와 commit pagination이 완전하고 diff가 truncated·ambiguous하지 않다.
|
||||
- Canonical Issue worktree와 PR head branch를 현재 실행이 안전하게 재사용할 수 있다.
|
||||
|
||||
`COMMENT` review도 exact head에 결합된 actionable code finding이면 다른 finding과 같은 matrix로 분류할 수 있다. 그러나 `COMMENT`의 `blocked_spec`, 계약 모순, 선택되지 않은 설계 선택 또는 source reality와 계약 충돌은 구현 재작업으로 해결하지 않는다. Discovery 재검토가 필요하다고 보고하고 source를 수정하지 않는다. `APPROVED` review만 존재하거나 actionable finding이 모두 최신 head에서 해소된 경우 write 없는 no-op으로 종료한다.
|
||||
Canonical input-gate의 `decision.rework_review_ids`는 현재 head·계약에 결합된 role별 최신 `REQUEST_CHANGES`만 포함한다. `rework`에서도 finding의 계약 근거·scope·독립 승인과 lease/worktree 조건을 별도로 확인한 뒤에만 수정한다. `COMMENT`는 자동 actionable 판정을 하지 않고 `blocked`로 남겨 main의 수동 분류 또는 별도 승인 절차로 넘긴다. 특히 `blocked_spec`, 계약 모순, 선택되지 않은 설계 선택 또는 source reality와 계약 충돌은 구현 재작업으로 해결하지 않으며 Discovery 재검토가 필요하다고 보고한다. `blocked` receipt로 source를 수정하거나 no-op을 주장하지 않는다. Current head·계약의 spec와 code-review role별 최신 유효 review가 모두 `APPROVED`인 fresh `no_op` receipt에서만 write 없는 종료를 한다. 다른 role의 승인·old-head/stale/dismissed review·본문의 해결 주장은 이 조건을 대체하지 않는다.
|
||||
|
||||
## Snapshot과 finding matrix
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# Ordered initial review input gate
|
||||
|
||||
## Canonical invocation and artifacts
|
||||
|
||||
Run the public entry point before choosing no-op or editing source:
|
||||
|
||||
```sh
|
||||
python3 -B harness/agents/impl/skills/gitea-issue-rework/scripts/rework_comment.py input-gate \
|
||||
--target <rework-input.json> --target-sha256 <exact-file-sha256> \
|
||||
--provider-contract <provider-contract.json> \
|
||||
--provider-contract-sha256 <exact-file-sha256> --actor-id <impl-stable-id> \
|
||||
--output <admitted-verification-run>/decision.json
|
||||
```
|
||||
|
||||
The canonical output is `<harness-root>/repositories/<owner>/<repository>/worktrees/issue-<issue>/verification/<run-id>/decision.json`. First prepare/admit this run root using the existing `verification_root.py prepare` procedure in `harness/operations/repository-cache.md`. The caller derives exact identity/path from authenticated workflow state. The CLI checks private descriptor-relative filesystem safety; it does **not** authenticate a broker lease or canonical verification-root identity. Do not infer authority from an arbitrary `--output` path.
|
||||
|
||||
Use a fresh run and receipt after source/head/decision inputs change. Existing outputs are refused before credential loading or GETs; atomic no-clobber publication also preserves a file created concurrently. There is no update-in-place mode for this immutable artifact. The entire serialized receipt, including raw snapshot, is bounded to 1,000,000 bytes and fails closed rather than truncating. Legacy completion output size/replacement behavior is unchanged.
|
||||
|
||||
## Independent input
|
||||
|
||||
The target file is exactly schema `gitea-agent.rework-input/v1`:
|
||||
|
||||
```json
|
||||
{"schema":"gitea-agent.rework-input/v1","target":{"owner":"acme","repository":"demo","issue":7,"pr":9},"head":"<40 lowercase hex>","contract_sha256":"<64 lowercase hex>"}
|
||||
```
|
||||
|
||||
Use the independently verified current PR head and SHA-256 of the exact fresh Source Issue UTF-8 body. Names/positive integer IDs/digests/field sets are strict; Issue and PR differ. No selected review ID, final rework head, rendered comment, or `expected.json` is required. The schema and explicit provider contract are validated before credentials or network access.
|
||||
|
||||
Two authenticated GET-only captures reuse identity-first native metadata, repository/PR/Issue/author binding, complete bounded pagination, Link provenance, per-review inline/count capability fallback and drift checks. The initial phase also requires the repository's native default branch to match PR base ref, the exact discovery-draft/v2 first line, and exact Source Issue digest. Discovery structural approval, unresolved-question checks and edit authorization are independent upstream requirements, not claims made by this gate.
|
||||
|
||||
## Derived decisions
|
||||
|
||||
The `gitea-agent.rework-input-gate/v1` receipt retains parsed native records, provider row order and body text in `raw_input_snapshot`, bound by `raw_input_sha256`; it is not a byte archive of HTTP serialization. A separate derived timeline records every review's ID, native author stable ID/login/role, state, submitted timestamp, reviewed SHA, stale/dismissed flags, canonical body marker, body digest and eligibility/exclusion reasons. Sorting does not mutate raw evidence.
|
||||
|
||||
Order by UTC instant with exact up-to-nine-digit fractional precision, then numeric review ID for deterministic display ties. ID order is not causal attestation. Conflicting same-instant same-role state/body/marker records block and have no selected review. Markers use the actual canonical code FIRST-line and spec LAST-line formats; spec result digest and correlation match its renderer's separator-LF algorithm. Current authoritative marker/head/contract/state contradictions block. Role IDs must be positive, distinct from impl, and consistent; unrelated roles cannot supply required approvals. Old-head history is recorded but cannot determine current-head action.
|
||||
|
||||
- `rework`: both roles have a current usable selection, and the only remaining decision reason is latest role-specific `REQUEST_CHANGES`. `decision.rework_review_ids` contains those request IDs only, never spec/code approvals. Validate their findings and authorization independently before source edits.
|
||||
- `no_op`: both spec and code-review role-specific latest current-head, nonstale, nondismissed reviews are `APPROVED` for the fresh contract. A spec approval cannot supersede a code request. Earlier same-role requests may be superseded by a later valid same-role approval, never by another role.
|
||||
- `blocked`: missing current roles, pending authoritative reviews, malformed/ambiguous bindings, same-instant conflicts, current manual `COMMENT`, or `blocked_spec` prevent automatic action. `blocked_spec` is explicit and routes to Discovery investigation, not impl repair. No keyword heuristic or marker alone proves actionable finding semantics.
|
||||
|
||||
This gate's no-op means skip implementation rework, not native review publisher idempotent reuse (which can reuse an existing REQUEST_CHANGES review). Publication, journals and their candidate-selection rules remain unchanged. A receipt is neither publication success nor edit permission, lease/worktree admission, full Discovery approval, independent reviewer-ID attestation, or causal event proof. Do not reuse an old receipt as authority; recheck provider/head/contract and independent upstream gates before an action.
|
||||
@@ -174,10 +174,20 @@ def main():
|
||||
live.add_argument('--baseline', required=True)
|
||||
live.add_argument('--baseline-sha256', required=True)
|
||||
live.add_argument('--action', choices=('create', 'reuse'), required=True)
|
||||
gate = sp.add_parser('input-gate')
|
||||
gate.add_argument('--target', required=True)
|
||||
gate.add_argument('--target-sha256', required=True)
|
||||
gate.add_argument('--provider-contract', required=True)
|
||||
gate.add_argument('--provider-contract-sha256', required=True)
|
||||
gate.add_argument('--actor-id', required=True, type=int)
|
||||
gate.add_argument('--output', required=True)
|
||||
args=p.parse_args()
|
||||
try:
|
||||
if args.cmd == 'render': do_render(args)
|
||||
elif args.cmd == 'verify': do_verify(args)
|
||||
elif args.cmd == 'input-gate':
|
||||
from rework_readback_cli import run_input_gate
|
||||
run_input_gate(args)
|
||||
else:
|
||||
from rework_readback_cli import run_live
|
||||
run_live(args, verify_readbacks)
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
"""Ordered, conservative review decision gate for rework input."""
|
||||
from __future__ import annotations
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
|
||||
SCHEMA = 'gitea-agent.rework-input/v1'
|
||||
RECEIPT_SCHEMA = 'gitea-agent.rework-input-gate/v1'
|
||||
ROLES = ('gitea-spec-agent', 'gitea-review-agent')
|
||||
NAME_RE = re.compile(r'^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$')
|
||||
SHA_RE = re.compile(r'^[0-9a-f]{40}$')
|
||||
DIGEST_RE = re.compile(r'^[0-9a-f]{64}$')
|
||||
TIME_RE = re.compile(r'^([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2})(?:\.([0-9]{1,9}))?(Z|[+-][0-9]{2}:[0-9]{2})$')
|
||||
|
||||
class InputGateError(ValueError):
|
||||
pass
|
||||
|
||||
def _obj(v, msg='object required'):
|
||||
if type(v) is not dict: raise InputGateError(msg)
|
||||
return v
|
||||
|
||||
def _id(v, name):
|
||||
if type(v) is not int or v < 1: raise InputGateError('invalid '+name)
|
||||
return v
|
||||
|
||||
def _sha(v, name, pattern):
|
||||
if type(v) is not str or not pattern.fullmatch(v): raise InputGateError('invalid '+name)
|
||||
return v
|
||||
|
||||
def digest(v):
|
||||
try:
|
||||
raw = json.dumps(v, sort_keys=True, ensure_ascii=False, allow_nan=False, separators=(',', ':')).encode('utf-8')
|
||||
except (TypeError, ValueError, UnicodeError):
|
||||
raise InputGateError('invalid JSON artifact') from None
|
||||
return hashlib.sha256(raw).hexdigest()
|
||||
|
||||
def validate_input(value):
|
||||
value = _obj(value)
|
||||
if set(value) != {'schema','target','head','contract_sha256'} or value.get('schema') != SCHEMA:
|
||||
raise InputGateError('invalid rework input schema')
|
||||
target = _obj(value.get('target'))
|
||||
if set(target) != {'owner','repository','issue','pr'}: raise InputGateError('invalid target fields')
|
||||
for name in ('owner','repository'):
|
||||
if type(target[name]) is not str or not NAME_RE.fullmatch(target[name]): raise InputGateError('invalid target '+name)
|
||||
_id(target['issue'], 'issue'); _id(target['pr'], 'pr')
|
||||
if target['issue'] == target['pr']: raise InputGateError('source Issue must differ from PR')
|
||||
_sha(value.get('head'), 'head', SHA_RE)
|
||||
_sha(value.get('contract_sha256'), 'contract_sha256', DIGEST_RE)
|
||||
return copy.deepcopy(value)
|
||||
|
||||
def _time_key(value):
|
||||
m = TIME_RE.fullmatch(value)
|
||||
if not m: raise InputGateError('invalid submitted_at')
|
||||
try:
|
||||
base = datetime.fromisoformat(m.group(1)).replace(tzinfo=timezone.utc)
|
||||
frac = int((m.group(2) or '').ljust(9, '0'))
|
||||
zone = m.group(3)
|
||||
if zone != 'Z':
|
||||
if int(zone[1:3]) > 23 or int(zone[4:]) > 59:
|
||||
raise InputGateError('invalid submitted_at offset')
|
||||
sign = 1 if zone[0] == '+' else -1
|
||||
off = int(zone[1:3])*3600 + int(zone[4:])*60
|
||||
base = base.replace(tzinfo=timezone(sign*__import__('datetime').timedelta(seconds=off)))
|
||||
epoch = base.astimezone(timezone.utc)
|
||||
return (int(epoch.timestamp()), frac)
|
||||
except (ValueError, OverflowError):
|
||||
raise InputGateError('invalid submitted_at') from None
|
||||
|
||||
def _marker_fields(body, prefix):
|
||||
lines = body.splitlines()
|
||||
matches = [i for i, line in enumerate(lines) if line.startswith(prefix)]
|
||||
if body.count(prefix) != 1 or len(matches) != 1: raise InputGateError('review marker missing or duplicate')
|
||||
index = matches[0]
|
||||
if prefix.endswith('code-review/v1 '):
|
||||
if index != 0: raise InputGateError('code review marker must be first line')
|
||||
marker = lines[index]
|
||||
pattern = (r'<!-- gitea-agent:code-review/v1 owner=([^ ]+) repository=([^ ]+) issue=([1-9][0-9]*) '
|
||||
r'pr=([1-9][0-9]*) head=([0-9a-f]{40}) contract-sha256=([0-9a-f]{64}) -->')
|
||||
m = re.fullmatch(pattern, marker)
|
||||
if not m: raise InputGateError('malformed code review marker')
|
||||
verdicts = [x for x in lines if x.startswith('- Verdict:')]
|
||||
if len(verdicts) != 1 or verdicts[0] not in ('- Verdict: `approve`','- Verdict: `request_changes`'):
|
||||
raise InputGateError('invalid code review verdict')
|
||||
return {'owner':m.group(1),'repository':m.group(2),'issue':int(m.group(3)),'pr':int(m.group(4)),
|
||||
'head':m.group(5),'contract_sha256':m.group(6),'verdict':verdicts[0][len('- Verdict: `'):-1],
|
||||
'marker':marker}
|
||||
if index != len(lines)-1: raise InputGateError('spec review marker must be last line')
|
||||
marker = lines[index]
|
||||
pattern = (r'<!-- gitea-agent:implementation-spec-review/v1 owner=([^ ]+) repository=([^ ]+) issue=([1-9][0-9]*) '
|
||||
r'pr=([1-9][0-9]*) head=([0-9a-f]{40}) contract-sha256=([0-9a-f]{64}) result-sha256=([0-9a-f]{64}) correlation=([0-9a-f]{64}) -->')
|
||||
m = re.fullmatch(pattern, marker)
|
||||
if not m: raise InputGateError('malformed spec review marker')
|
||||
if not body.endswith('\n'): raise InputGateError('invalid spec review body')
|
||||
marker_free = body[:body.rfind(marker)]
|
||||
# The canonical spec renderer adds one separator LF after its hashed body.
|
||||
if not marker_free.endswith('\n'): raise InputGateError('invalid spec review separator')
|
||||
result_sha = hashlib.sha256(marker_free[:-1].encode('utf-8')).hexdigest()
|
||||
if result_sha != m.group(7): raise InputGateError('spec review result digest mismatch')
|
||||
corr = hashlib.sha256('\0'.join([m.group(1),m.group(2),m.group(3),m.group(4),m.group(5),m.group(6),m.group(7)]).encode()).hexdigest()
|
||||
if corr != m.group(8): raise InputGateError('spec review correlation mismatch')
|
||||
verdicts = [x for x in lines if x.startswith('- Verdict:')]
|
||||
if len(verdicts) != 1 or verdicts[0] not in ('- Verdict: `approve`','- Verdict: `request_changes`','- Verdict: `blocked_spec`'):
|
||||
raise InputGateError('invalid spec review verdict')
|
||||
return {'owner':m.group(1),'repository':m.group(2),'issue':int(m.group(3)),'pr':int(m.group(4)),
|
||||
'head':m.group(5),'contract_sha256':m.group(6),'verdict':verdicts[0][len('- Verdict: `'):-1],
|
||||
'marker':marker}
|
||||
|
||||
def _validate_raw(adapter, raw, inp):
|
||||
target = inp['target']; prefix = '/repos/'+target['owner']+'/'+target['repository']
|
||||
# Existing canonical guards validate native metadata, endpoint identity, counts and bindings.
|
||||
target = dict(target, head=inp['head'])
|
||||
adapter.bind_input(target, raw, require_default_branch=True)
|
||||
adapter.validate_collection(raw['source_comments'], prefix+'/issues/'+str(target['issue'])+'/comments')
|
||||
adapter.validate_collection(raw['comments'], prefix+'/issues/'+str(target['pr'])+'/comments')
|
||||
adapter.validate_collection(raw['reviews'], prefix+'/pulls/'+str(target['pr'])+'/reviews', reviews=True)
|
||||
expected_inline = {str(row['id']) for row in raw['reviews']['rows']}
|
||||
if set(raw.get('inline', {})) != expected_inline:
|
||||
raise InputGateError('incomplete review comment endpoints')
|
||||
for rid, collected in raw['inline'].items():
|
||||
adapter.validate_collection(collected, prefix+'/pulls/'+str(target['pr'])+'/reviews/'+rid+'/comments',
|
||||
allow_missing=adapter.provider['inline']['mode'] == 'per_review_or_pr_count')
|
||||
adapter.counts(raw)
|
||||
source = raw['source_issue']
|
||||
if source['body'].splitlines()[:1] != ['<!-- gitea-agent:discovery-draft/v2 -->']:
|
||||
raise InputGateError('invalid discovery draft marker')
|
||||
if hashlib.sha256(source['body'].encode('utf-8')).hexdigest() != inp['contract_sha256']:
|
||||
raise InputGateError('Source Issue contract digest mismatch')
|
||||
if raw['pr']['head']['sha'] != inp['head']: raise InputGateError('PR head mismatch')
|
||||
return prefix
|
||||
|
||||
def derive_decision(raw, inp, impl_actor_id):
|
||||
inp = validate_input(inp); _id(impl_actor_id, 'actor_id')
|
||||
timeline=[]; role_ids={}
|
||||
for review in raw['reviews']['rows']:
|
||||
user = _obj(review.get('user')); login = user.get('login'); rid = _id(review.get('id'),'review id')
|
||||
role = login if login in ROLES else 'unknown'
|
||||
if login in ROLES:
|
||||
uid = _id(user.get('id'),'reviewer id')
|
||||
if uid == impl_actor_id: raise InputGateError('reviewer identity equals implementation actor')
|
||||
prior = role_ids.get(login)
|
||||
if prior is not None and prior != uid: raise InputGateError('inconsistent reviewer identity')
|
||||
if prior is None and uid in role_ids.values(): raise InputGateError('reviewer roles share identity')
|
||||
role_ids[login] = uid
|
||||
marker = None; marker_error = None
|
||||
try:
|
||||
if role in ROLES: marker = _marker_fields(review.get('body',''), '<!-- gitea-agent:'+('implementation-spec-review/v1 ' if role == 'gitea-spec-agent' else 'code-review/v1 '))
|
||||
except InputGateError as exc: marker_error = str(exc)
|
||||
if marker is not None:
|
||||
t=inp['target']
|
||||
if (marker['owner'],marker['repository'],marker['issue'],marker['pr']) != (t['owner'],t['repository'],t['issue'],t['pr']) or marker['head'] != review['commit_id'] or marker['contract_sha256'] != inp['contract_sha256']:
|
||||
marker_error='review marker binding mismatch'
|
||||
current = review['commit_id'] == inp['head']
|
||||
reason=[]
|
||||
if not current: reason.append('old_head')
|
||||
if review.get('stale'): reason.append('stale')
|
||||
if review.get('dismissed'): reason.append('dismissed')
|
||||
if marker_error: reason.append('malformed_marker' if 'marker' in marker_error or 'verdict' in marker_error or 'digest' in marker_error or 'correlation' in marker_error else 'marker_binding')
|
||||
if role == 'unknown': reason.append('unknown_role')
|
||||
if review['state'] == 'PENDING': reason.append('pending')
|
||||
if review['state'] == 'COMMENT': reason.append('comment_manual_classification')
|
||||
if marker is not None:
|
||||
compatible = {'approve': 'APPROVED', 'request_changes': 'REQUEST_CHANGES',
|
||||
'blocked_spec': 'COMMENT'}[marker['verdict']]
|
||||
if review['state'] != compatible:
|
||||
reason.append('state_marker_contradiction')
|
||||
if role in ROLES and current and marker_error: reason.append('authoritative_marker_error')
|
||||
timeline.append({'id':rid,'author':{'id':user['id'],'login':login,'role':role},'state':review['state'],
|
||||
'submitted_at':review['submitted_at'],'reviewed_sha':review['commit_id'],'stale':review['stale'],'dismissed':review['dismissed'],
|
||||
'canonical_body_marker': marker['marker'] if marker else None,'body_sha256':hashlib.sha256(review['body'].encode()).hexdigest(),
|
||||
'reason_codes':reason,'eligible':role in ROLES and current and review['state'] != 'COMMENT' and not review['stale'] and not review['dismissed'] and marker is not None and marker_error is None and 'state_marker_contradiction' not in reason,
|
||||
'_time':_time_key(review['submitted_at']),'_verdict':marker['verdict'] if marker else None,'_role':role,'_marker_error':marker_error})
|
||||
if len(timeline) > 1000: raise InputGateError('decision artifact too large')
|
||||
timeline.sort(key=lambda x:(x['_time'],x['id']))
|
||||
reasons=[]; status='blocked'; selected={}
|
||||
if any('authoritative_marker_error' in x['reason_codes'] for x in timeline): reasons.append('authoritative_marker_error')
|
||||
if any('state_marker_contradiction' in x['reason_codes'] and x['reviewed_sha'] == inp['head'] for x in timeline): reasons.append('state_marker_contradiction')
|
||||
if any(x['_role'] in ROLES and x['reviewed_sha'] == inp['head'] and x['state'] == 'COMMENT' and not x['stale'] and not x['dismissed'] for x in timeline):
|
||||
reasons.append('comment_manual_classification')
|
||||
if any(x['_role'] == 'gitea-spec-agent' and x['reviewed_sha'] == inp['head']
|
||||
and not x['stale'] and not x['dismissed'] and x['_verdict'] == 'blocked_spec' for x in timeline):
|
||||
reasons.append('blocked_spec')
|
||||
for role in ROLES:
|
||||
all_current = [x for x in timeline if x['_role'] == role and x['reviewed_sha'] == inp['head']
|
||||
and not x['stale'] and not x['dismissed']]
|
||||
by_time = {}
|
||||
for row in all_current:
|
||||
by_time.setdefault(row['_time'], []).append(row)
|
||||
conflicting_role = False
|
||||
for same_time in by_time.values():
|
||||
signatures = {(x['state'], x['body_sha256'], x.get('canonical_body_marker')) for x in same_time}
|
||||
if len(signatures) > 1:
|
||||
reasons.append('conflicting_same_time_reviews')
|
||||
selected[role] = None
|
||||
conflicting_role = True
|
||||
break
|
||||
if conflicting_role:
|
||||
continue
|
||||
rows=[x for x in timeline if x['_role']==role and x['eligible']]
|
||||
if any(x['state']=='PENDING' and x['_role']==role and x['reviewed_sha']==inp['head'] and not x['stale'] and not x['dismissed'] for x in timeline):
|
||||
reasons.append('pending_authoritative_review')
|
||||
if not rows:
|
||||
selected[role]=None; reasons.append('missing_current_review')
|
||||
continue
|
||||
latest_time=rows[-1]['_time']; same=[x for x in rows if x['_time']==latest_time]
|
||||
verdicts={x['_verdict'] for x in same}
|
||||
if len(verdicts)>1:
|
||||
reasons.append('conflicting_same_time_reviews'); selected[role]=None; continue
|
||||
latest=same[-1]; selected[role]=latest['id']
|
||||
if latest['_verdict']=='request_changes': reasons.append('request_changes')
|
||||
elif latest['_verdict']=='blocked_spec': reasons.append('blocked_spec')
|
||||
if not reasons:
|
||||
status='no_op'
|
||||
elif reasons and set(reasons) == {'request_changes'}:
|
||||
status='rework'
|
||||
else: status='blocked'
|
||||
for row in timeline:
|
||||
for key in ('_time','_verdict','_role','_marker_error'): row.pop(key,None)
|
||||
decision={'status':status,'reason_codes':sorted(set(reasons)),'target':copy.deepcopy(inp['target']),'head':inp['head'],
|
||||
'contract_sha256':inp['contract_sha256'],'actor':{'id':impl_actor_id,'login':'gitea-impl-agent','role':'gitea-impl-agent'},
|
||||
'selected_review_ids':selected,'timeline':timeline,
|
||||
'rework_review_ids':[row['id'] for row in timeline if status == 'rework'
|
||||
and row['id'] in selected.values() and row['state'] == 'REQUEST_CHANGES']}
|
||||
if len(json.dumps(decision,ensure_ascii=False,sort_keys=True).encode()) > 1_000_000: raise InputGateError('decision artifact too large')
|
||||
return decision
|
||||
|
||||
def build_receipt(raw, inp, decision):
|
||||
receipt = {'schema':RECEIPT_SCHEMA,'status':decision['status'],'reason_codes':decision['reason_codes'],
|
||||
'target':decision['target'],'head':decision['head'],'contract_sha256':decision['contract_sha256'],
|
||||
'actor':decision['actor'],'input_sha256':digest(inp),'raw_input_snapshot':copy.deepcopy(raw),
|
||||
'raw_input_sha256':digest(raw),'fresh_GET_provenance':{'kind':'fresh_authenticated_GET','read_only':True},
|
||||
'decision':decision}
|
||||
if len(json.dumps(receipt, ensure_ascii=False, sort_keys=True, allow_nan=False).encode('utf-8')) > 1_000_000:
|
||||
raise InputGateError('receipt artifact too large')
|
||||
return receipt
|
||||
@@ -276,7 +276,7 @@ class ReworkReadbackAdapter:
|
||||
raise ReadbackError('expected renderer binding mismatch')
|
||||
return dict(target, review=expected['review'], reviewed_head=expected['reviewed_head'], head=expected['head'])
|
||||
|
||||
def bind(self, target, raw):
|
||||
def bind_input(self, target, raw, *, require_default_branch=False):
|
||||
prefix = ORIGIN + '/repos/' + target['owner'] + '/' + target['repository']
|
||||
html = 'https://gitea.jsfamily.quest/' + target['owner'] + '/' + target['repository']
|
||||
repo = object_value(raw['repository'])
|
||||
@@ -310,12 +310,20 @@ class ReworkReadbackAdapter:
|
||||
raise ReadbackError('PR base repository mismatch')
|
||||
if head_branch['sha'] != target['head']:
|
||||
raise ReadbackError('PR head mismatch')
|
||||
if require_default_branch:
|
||||
default_branch = repo.get('default_branch')
|
||||
if type(default_branch) is not str or not default_branch or any(ord(c) < 33 for c in default_branch):
|
||||
raise ReadbackError('repository default branch missing')
|
||||
if base_branch['ref'] != default_branch:
|
||||
raise ReadbackError('PR base default branch mismatch')
|
||||
# Fork heads are valid; the target repository is bound through base and endpoint.
|
||||
relation = object_value(underlying.get('pull_request'))
|
||||
if (underlying.get('html_url') not in (html+'/pulls/'+str(target['pr']), html+'/issues/'+str(target['pr']))
|
||||
or relation.get('html_url') != html+'/pulls/'+str(target['pr'])
|
||||
or ('url' in relation and relation.get('url') != html+'/pulls/'+str(target['pr']))):
|
||||
raise ReadbackError('underlying Issue PR binding mismatch')
|
||||
def bind(self, target, raw):
|
||||
self.bind_input(target, raw)
|
||||
review = next((row for row in raw['reviews']['rows'] if row.get('id') == target['review']), None)
|
||||
if review is None or review.get('commit_id') != target['reviewed_head']:
|
||||
raise ReadbackError('source review binding mismatch')
|
||||
@@ -359,7 +367,7 @@ class ReworkReadbackAdapter:
|
||||
raise ReadbackError('source collection count mismatch')
|
||||
return counts
|
||||
|
||||
def capture(self, target):
|
||||
def capture_input(self, target, *, require_default_branch=True):
|
||||
identity, _ = self.get('/user')
|
||||
if integer(identity.get('id'), 1) != self.actor_id or identity.get('login') != 'gitea-impl-agent':
|
||||
raise ReadbackError('authenticated identity mismatch')
|
||||
@@ -378,10 +386,36 @@ class ReworkReadbackAdapter:
|
||||
raw[name] = self.collection(endpoint)
|
||||
raw['inline'] = {str(review['id']):self.collection(prefix+'/pulls/'+str(target['pr'])+
|
||||
'/reviews/'+str(review['id'])+'/comments', allow_missing=self.provider['inline']['mode'] == 'per_review_or_pr_count') for review in raw['reviews']['rows']}
|
||||
self.bind(target, raw)
|
||||
self.bind_input(target, raw, require_default_branch=require_default_branch)
|
||||
self.counts(raw)
|
||||
return raw
|
||||
|
||||
def capture(self, target):
|
||||
raw = self.capture_input(target, require_default_branch=False)
|
||||
self.bind(target, raw)
|
||||
return raw
|
||||
|
||||
def input_snapshot(self, value):
|
||||
import importlib.util, sys
|
||||
path = __import__('pathlib').Path(__file__).with_name('rework_input_gate.py')
|
||||
if 'rework_input_gate' not in sys.modules:
|
||||
spec = importlib.util.spec_from_file_location('rework_input_gate', path)
|
||||
if spec is None or spec.loader is None: raise ReadbackError('input gate unavailable')
|
||||
gate = importlib.util.module_from_spec(spec); sys.modules[spec.name] = gate; spec.loader.exec_module(gate)
|
||||
else:
|
||||
gate = sys.modules['rework_input_gate']
|
||||
build_receipt, derive_decision, digest, validate_input = gate.build_receipt, gate.derive_decision, gate.digest, gate.validate_input
|
||||
value = validate_input(value)
|
||||
target = dict(value['target'], head=value['head'], contract_sha256=value['contract_sha256'])
|
||||
first = self.capture_input(target)
|
||||
gate._validate_raw(self, first, value)
|
||||
second = self.capture_input(target)
|
||||
gate._validate_raw(self, second, value)
|
||||
if digest(first) != digest(second):
|
||||
raise ReadbackError('concurrent provider drift')
|
||||
decision = derive_decision(first, value, self.actor_id)
|
||||
return build_receipt(first, value, decision)
|
||||
|
||||
def snapshot(self, expected):
|
||||
target = self.target(expected)
|
||||
raw = self.capture(target)
|
||||
|
||||
@@ -191,8 +191,9 @@ def read_bound_json(path, expected_sha256=None):
|
||||
|
||||
class OutputFile:
|
||||
"""Pin a private output directory, and replace only after successful verification."""
|
||||
def __init__(self, path):
|
||||
def __init__(self, path, *, initial_only=False):
|
||||
self.path = Path(path)
|
||||
self.initial_only = initial_only
|
||||
self.fd, self.name = _open_parent(self.path)
|
||||
try:
|
||||
parent = os.fstat(self.fd)
|
||||
@@ -200,6 +201,8 @@ class OutputFile:
|
||||
raise ValueError('private output directory required')
|
||||
self.parent_identity = (parent.st_dev, parent.st_ino)
|
||||
self.original = self.identity()
|
||||
if self.initial_only and self.original is not None:
|
||||
raise ValueError('output already exists')
|
||||
self.done = False
|
||||
except BaseException:
|
||||
os.close(self.fd)
|
||||
@@ -225,7 +228,7 @@ class OutputFile:
|
||||
|
||||
def write(self, value):
|
||||
raw = (json.dumps(value, ensure_ascii=False, sort_keys=True, allow_nan=False) + '\n').encode()
|
||||
if len(raw) > 16_000_000 or self.done:
|
||||
if len(raw) > (1_000_000 if self.initial_only else 16_000_000) or self.done:
|
||||
raise ValueError('invalid output size or repeated write')
|
||||
self.check_parent()
|
||||
if self.identity() != self.original:
|
||||
@@ -242,7 +245,15 @@ class OutputFile:
|
||||
self.check_parent()
|
||||
if self.identity() != self.original:
|
||||
raise ValueError('output target drift')
|
||||
os.replace(temporary, self.name, src_dir_fd=self.fd, dst_dir_fd=self.fd)
|
||||
if self.initial_only:
|
||||
try:
|
||||
os.link(temporary, self.name, src_dir_fd=self.fd, dst_dir_fd=self.fd,
|
||||
follow_symlinks=False)
|
||||
except FileExistsError:
|
||||
raise ValueError('output created during capture') from None
|
||||
os.unlink(temporary, dir_fd=self.fd)
|
||||
else:
|
||||
os.replace(temporary, self.name, src_dir_fd=self.fd, dst_dir_fd=self.fd)
|
||||
self.done = True
|
||||
os.fsync(self.fd)
|
||||
self.check_parent()
|
||||
@@ -256,6 +267,21 @@ class OutputFile:
|
||||
def __exit__(self, *args): os.close(self.fd)
|
||||
|
||||
|
||||
def reject_existing_output(path):
|
||||
"""Initial gate artifacts are immutable run-specific outputs."""
|
||||
parent, name = _open_parent(path)
|
||||
try:
|
||||
try:
|
||||
metadata = os.stat(name, dir_fd=parent, follow_symlinks=False)
|
||||
except FileNotFoundError:
|
||||
return
|
||||
if not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1 or metadata.st_uid != os.getuid():
|
||||
raise ValueError('unsafe existing output target')
|
||||
raise ValueError('output already exists')
|
||||
finally:
|
||||
os.close(parent)
|
||||
|
||||
|
||||
def run_live(args, verifier):
|
||||
"""Capture a baseline or feed computed fresh readbacks into the existing verifier."""
|
||||
try:
|
||||
@@ -316,3 +342,41 @@ def run_live(args, verifier):
|
||||
print(json.dumps({'output': str(args.output), 'sha256': digest, 'read_only': True}))
|
||||
except Exception:
|
||||
raise ValueError('live readback failed; output not verified') from None
|
||||
|
||||
|
||||
def run_input_gate(args):
|
||||
"""Capture and decide initial input without overwriting an existing receipt."""
|
||||
input_paths = [Path(args.target), Path(args.provider_contract)]
|
||||
if Path(args.output) in input_paths:
|
||||
raise ValueError('output aliases protected input')
|
||||
with OutputFile(args.output, initial_only=True) as output:
|
||||
target = read_bound_json(args.target, args.target_sha256)
|
||||
contract = read_bound_json(args.provider_contract, args.provider_contract_sha256)
|
||||
import importlib.util, sys
|
||||
gate_path = Path(__file__).with_name('rework_input_gate.py')
|
||||
gate_spec = importlib.util.spec_from_file_location('rework_input_gate', gate_path)
|
||||
if gate_spec is None or gate_spec.loader is None: raise ValueError('input gate unavailable')
|
||||
gate = importlib.util.module_from_spec(gate_spec); sys.modules[gate_spec.name] = gate; gate_spec.loader.exec_module(gate)
|
||||
target = gate.validate_input(target)
|
||||
core_path = Path(__file__).with_name('rework_readback.py')
|
||||
core_spec = importlib.util.spec_from_file_location('gitea_input_preflight', core_path)
|
||||
if core_spec is None or core_spec.loader is None: raise ValueError('canonical readback core unavailable')
|
||||
core = importlib.util.module_from_spec(core_spec); sys.modules[core_spec.name] = core; core_spec.loader.exec_module(core)
|
||||
core.ReworkReadbackAdapter(lambda path: (_ for _ in ()).throw(ValueError('preflight transport must not run')),
|
||||
origin=ORIGIN + '/api/v1', credential_source='$HERMES_HOME/.env:GITEA_TOKEN',
|
||||
actor_id=args.actor_id, provider_contract=contract)
|
||||
home = os.environ.get('HERMES_HOME')
|
||||
if not home:
|
||||
raise ValueError('role profile not bound')
|
||||
credential = load_role_token(home)
|
||||
transport = RestTransport(credential)
|
||||
path = Path(__file__).with_name('rework_readback.py')
|
||||
spec = importlib.util.spec_from_file_location('gitea_impl_readback_core', path)
|
||||
if spec is None or spec.loader is None: raise ValueError('canonical readback core unavailable')
|
||||
module = importlib.util.module_from_spec(spec); sys.modules[spec.name] = module; spec.loader.exec_module(module)
|
||||
adapter = module.ReworkReadbackAdapter(transport, origin=ORIGIN + '/api/v1',
|
||||
credential_source='$HERMES_HOME/.env:GITEA_TOKEN', actor_id=args.actor_id, provider_contract=contract)
|
||||
result = adapter.input_snapshot(target)
|
||||
if credential in json.dumps(result, ensure_ascii=False): raise ValueError('credential material in artifact')
|
||||
output.write(result)
|
||||
print(json.dumps({'output':str(args.output), 'read_only':True, 'immutable':True}))
|
||||
|
||||
@@ -41,6 +41,13 @@ create/reuse deltas, metadata causality and failed-receipt preservation are
|
||||
fail-closed. The workflow contract and canonical runtime artifact paths are in
|
||||
`harness/agents/impl/skills/gitea-issue-rework/references/readback.md`.
|
||||
|
||||
Initial input-gate coverage is in `harness/tests/impl/test_rework_input_gate.py`.
|
||||
It exercises the real public CLI and canonical spec renderer, mixed-role/permutation
|
||||
and same-role supersession decisions, precise timestamp/tie handling, marker/state/
|
||||
source binding, complete raw validation/drift, immutable capture and atomic output
|
||||
races, and new/legacy output budgets. Synthetic transport is GET-only; these
|
||||
regressions do not claim live Gitea or native deployment execution.
|
||||
|
||||
The review native adapter's registered-worktree fixture accepts only exact
|
||||
`gitea-impl-agent` or read-only legacy `impl` creator-role literals. It preserves
|
||||
`state.json` bytes/mode/mtime/inode and rejects malformed role values without
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
import importlib.util
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT = Path(__file__).parents[2] / 'agents/impl/skills/gitea-issue-rework/scripts/rework_input_gate.py'
|
||||
|
||||
class InputGateTests(unittest.TestCase):
|
||||
def load(self):
|
||||
spec = importlib.util.spec_from_file_location('rework_input_gate', SCRIPT)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
def test_valid_input_schema_is_accepted(self):
|
||||
gate = self.load()
|
||||
value = {'schema':'gitea-agent.rework-input/v1',
|
||||
'target':{'owner':'acme','repository':'demo','issue':7,'pr':9},
|
||||
'head':'a'*40, 'contract_sha256':'b'*64}
|
||||
self.assertEqual(gate.validate_input(value), value)
|
||||
|
||||
def test_initial_output_is_immutable_and_rejected_before_credential_or_get(self):
|
||||
cli_path = SCRIPT.with_name('rework_readback_cli.py')
|
||||
spec = importlib.util.spec_from_file_location('rework_readback_cli', cli_path)
|
||||
cli = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(cli)
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
output = Path(root) / 'decision.json'
|
||||
output.write_bytes(b'old\n')
|
||||
output.chmod(0o600)
|
||||
mode = output.stat().st_mode
|
||||
with self.assertRaisesRegex(ValueError, 'output already exists'):
|
||||
cli.reject_existing_output(str(output))
|
||||
self.assertEqual(output.read_bytes(), b'old\n')
|
||||
self.assertEqual(output.stat().st_mode, mode)
|
||||
|
||||
class OrderedDecisionTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
import copy, hashlib
|
||||
from harness.tests.impl import test_rework_readback as rb
|
||||
from harness.tests.spec import test_implementation_spec_review_support as spec_tests
|
||||
self.rb = rb.ReworkReadbackTests(); self.rb.setUp(); self.addCleanup(self.rb.doCleanups)
|
||||
self.rb.repo['default_branch'] = 'main'
|
||||
self.rb.source['body'] = '<!-- gitea-agent:discovery-draft/v2 -->\nSynthetic approved source contract.\n'
|
||||
self.inp = {'schema':'gitea-agent.rework-input/v1', 'target':{'owner':'acme','repository':'demo','issue':7,'pr':9},
|
||||
'head':'b'*40, 'contract_sha256':hashlib.sha256(self.rb.source['body'].encode()).hexdigest()}
|
||||
self.rows=[]
|
||||
for role, rid, uid in [('gitea-spec-agent',301,33), ('gitea-review-agent',302,34)]:
|
||||
row = copy.deepcopy(self.rb.reviews[0]); row.update(id=rid, commit_id=self.inp['head'], state='APPROVED',
|
||||
user={'id':uid,'login':role}, stale=False, dismissed=False,
|
||||
html_url=self.rb.pr['html_url']+'#pullreview-'+str(rid))
|
||||
if role == 'gitea-spec-agent':
|
||||
result = spec_tests.result(); result['target'] = dict(self.inp['target']); result['target']['pull'] = result['target'].pop('pr'); result['target']['head']=self.inp['head']; result['contract_sha256']=self.inp['contract_sha256']
|
||||
row['body']=spec_tests.mod.render_review(result, {'src/a.py','tests/test_a.py'}, declaration={'mode':'none'})
|
||||
else:
|
||||
row['body']=self.code_body('approve')
|
||||
self.rows.append(row)
|
||||
self.set_rows(self.rows)
|
||||
original = self.rb.request
|
||||
def request(path):
|
||||
from urllib.parse import urlsplit
|
||||
import re
|
||||
if re.fullmatch(r'/repos/acme/demo/pulls/9/reviews/[1-9][0-9]*/comments', urlsplit(path).path):
|
||||
self.rb.calls.append(path); return [], {'X-Total-Count':'0'}, 200
|
||||
return original(path)
|
||||
self.rb.request=request
|
||||
|
||||
def code_body(self, verdict):
|
||||
return '<!-- gitea-agent:code-review/v1 owner=acme repository=demo issue=7 pr=9 head='+self.inp['head']+' contract-sha256='+self.inp['contract_sha256']+' -->\n- Verdict: `'+verdict+'`\nBounded native code-review fixture.\n'
|
||||
|
||||
def set_rows(self, rows):
|
||||
import copy
|
||||
self.rb.reviews=copy.deepcopy(rows)
|
||||
self.rb.pr['comments']=self.rb.underlying['comments']=self.rb.pr['review_comments']=len(rows)
|
||||
|
||||
def snapshot(self):
|
||||
return self.rb.adapter().input_snapshot(self.inp)
|
||||
|
||||
def test_real_spec_renderer_approval_is_accepted(self):
|
||||
receipt=self.snapshot()
|
||||
self.assertEqual(receipt['status'],'no_op',receipt['reason_codes'])
|
||||
|
||||
def test_fresh_source_contract_digest_is_enforced(self):
|
||||
self.assertEqual(self.snapshot()['status'], 'no_op')
|
||||
self.rb.source['body'] += 'Unexpected contract mutation.\n'
|
||||
with self.assertRaisesRegex(ValueError, 'contract|digest'):
|
||||
self.snapshot()
|
||||
|
||||
def test_native_state_marker_contradiction_is_blocked(self):
|
||||
import copy
|
||||
rows = copy.deepcopy(self.rows)
|
||||
code = next(row for row in rows if row['user']['login'] == 'gitea-review-agent')
|
||||
code['body'] = self.code_body('request_changes')
|
||||
code['state'] = 'APPROVED'
|
||||
self.set_rows(rows)
|
||||
receipt = self.snapshot()
|
||||
self.assertEqual(receipt['status'], 'blocked')
|
||||
self.assertIn('state_marker_contradiction', receipt['reason_codes'])
|
||||
|
||||
def test_legacy_output_budget_is_unchanged(self):
|
||||
cli_path = SCRIPT.with_name('rework_readback_cli.py')
|
||||
spec = importlib.util.spec_from_file_location('legacy_budget_probe', cli_path)
|
||||
cli = importlib.util.module_from_spec(spec); spec.loader.exec_module(cli)
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
output=Path(td)/'legacy.json'
|
||||
try:
|
||||
with cli.OutputFile(output) as writer: writer.write({'data':'x'*1_000_001})
|
||||
except ValueError as exc:
|
||||
self.fail('legacy output below original 16MB budget must remain accepted: '+str(exc))
|
||||
self.assertGreater(output.stat().st_size, 1_000_000)
|
||||
|
||||
def test_mixed_roles_permutations_require_rework_without_raw_mutation(self):
|
||||
import copy
|
||||
rows=copy.deepcopy(self.rows); rows[1].update(state='REQUEST_CHANGES',body=self.code_body('request_changes'))
|
||||
for ordered in (rows, list(reversed(rows))):
|
||||
self.set_rows(ordered); before=copy.deepcopy(self.rb.reviews)
|
||||
receipt=self.snapshot()
|
||||
self.assertEqual(receipt['status'],'rework',receipt['reason_codes'])
|
||||
self.assertEqual(receipt['decision']['selected_review_ids'], {'gitea-spec-agent':301,'gitea-review-agent':302})
|
||||
self.assertEqual(self.rb.reviews,before)
|
||||
self.assertEqual(receipt['raw_input_snapshot']['reviews']['rows'], before)
|
||||
self.assertEqual([x['id'] for x in receipt['decision']['timeline']], [301,302])
|
||||
|
||||
def test_only_same_role_later_approval_supersedes_request(self):
|
||||
import copy
|
||||
rows=copy.deepcopy(self.rows); rows[1].update(state='REQUEST_CHANGES',body=self.code_body('request_changes'))
|
||||
self.set_rows(rows); self.assertEqual(self.snapshot()['status'],'rework')
|
||||
later=copy.deepcopy(self.rows[1]);later.update(id=303,submitted_at='2026-01-02T00:00:00Z',updated_at='2026-01-02T00:00:00Z',html_url=self.rb.pr['html_url']+'#pullreview-303')
|
||||
self.set_rows([later,*rows]); receipt=self.snapshot()
|
||||
self.assertEqual(receipt['status'],'no_op',receipt['reason_codes'])
|
||||
self.assertEqual(receipt['decision']['selected_review_ids']['gitea-review-agent'],303)
|
||||
|
||||
def test_missing_stale_and_dismissed_current_roles_block(self):
|
||||
import copy
|
||||
self.assertEqual(self.snapshot()['status'],'no_op')
|
||||
for rows in ([self.rows[0]], [self.rows[1]], []):
|
||||
self.set_rows(rows); self.assertEqual(self.snapshot()['status'],'blocked')
|
||||
for field in ('stale','dismissed'):
|
||||
rows=copy.deepcopy(self.rows);rows[1][field]=True;self.set_rows(rows)
|
||||
self.assertEqual(self.snapshot()['status'],'blocked')
|
||||
|
||||
def test_current_pending_and_manual_comments_never_allow_noop(self):
|
||||
import copy
|
||||
self.assertEqual(self.snapshot()['status'],'no_op')
|
||||
for state in ('PENDING','COMMENT'):
|
||||
rows=copy.deepcopy(self.rows);rows[1]['state']=state;self.set_rows(rows)
|
||||
self.assertEqual(self.snapshot()['status'],'blocked')
|
||||
|
||||
def test_blocked_spec_comment_dominates_code_request_changes(self):
|
||||
import copy
|
||||
from harness.tests.spec import test_implementation_spec_review_support as spec_tests
|
||||
result=spec_tests.result('blocked_spec');result['target']={'owner':'acme','repository':'demo','issue':7,'pull':9,'head':self.inp['head']};result['contract_sha256']=self.inp['contract_sha256'];result['criteria'][0]['result']='indeterminate';result['scope']['spec_conflicts']=['src/a.py']
|
||||
rows=copy.deepcopy(self.rows);rows[0].update(state='COMMENT',body=spec_tests.mod.render_review(result,{'src/a.py','tests/test_a.py'},declaration={'mode':'none'}));rows[1].update(state='REQUEST_CHANGES',body=self.code_body('request_changes'))
|
||||
self.set_rows(rows);receipt=self.snapshot();self.assertEqual(receipt['status'],'blocked')
|
||||
self.assertIn('blocked_spec',receipt['reason_codes'])
|
||||
|
||||
def test_marker_contract_and_target_defects_block_rework(self):
|
||||
import copy
|
||||
self.assertEqual(self.snapshot()['status'],'no_op')
|
||||
for body in ('missing marker',self.code_body('approve').replace('issue=7','issue=8'),self.code_body('approve').replace(self.inp['contract_sha256'],'f'*64)):
|
||||
rows=copy.deepcopy(self.rows);rows[1]['body']=body;self.set_rows(rows)
|
||||
self.assertEqual(self.snapshot()['status'],'blocked')
|
||||
|
||||
def test_unknown_role_cannot_replace_authoritative_code_review(self):
|
||||
import copy
|
||||
self.assertEqual(self.snapshot()['status'],'no_op')
|
||||
unknown=copy.deepcopy(self.rows[1]);unknown['user']={'id':99,'login':'reader'}
|
||||
self.set_rows([self.rows[0],unknown]);receipt=self.snapshot()
|
||||
self.assertEqual(receipt['status'],'blocked');self.assertIn('unknown_role',receipt['decision']['timeline'][1]['reason_codes'])
|
||||
|
||||
def test_native_rows_source_marker_and_default_branch_fail_closed(self):
|
||||
import copy
|
||||
self.assertEqual(self.snapshot()['status'],'no_op')
|
||||
for field,bad in [('id',True),('state','UNRECOGNIZED'),('stale','false'),('dismissed',None),('submitted_at','not-a-timestamp')]:
|
||||
rows=copy.deepcopy(self.rows);rows[1][field]=bad;self.set_rows(rows)
|
||||
with self.subTest(field=field),self.assertRaises(ValueError):self.snapshot()
|
||||
self.set_rows(self.rows)
|
||||
original=self.rb.source['body'];self.rb.source['body']=original.replace(' -->',' --> suffix',1)
|
||||
import hashlib
|
||||
self.inp['contract_sha256']=hashlib.sha256(self.rb.source['body'].encode()).hexdigest()
|
||||
with self.assertRaisesRegex(ValueError,'discovery'):self.snapshot()
|
||||
self.rb.source['body']=original;self.inp['contract_sha256']=hashlib.sha256(original.encode()).hexdigest();self.rb.pr['base']['ref']='other'
|
||||
with self.assertRaisesRegex(ValueError,'default branch'):self.snapshot()
|
||||
|
||||
def test_duplicate_review_ids_and_capture_drift_fail_closed(self):
|
||||
import copy
|
||||
self.assertEqual(self.snapshot()['status'],'no_op')
|
||||
self.set_rows([*self.rows,copy.deepcopy(self.rows[1])])
|
||||
with self.assertRaisesRegex(ValueError,'duplicate'):self.snapshot()
|
||||
self.set_rows(self.rows);old=self.rb.request;users=[0]
|
||||
def request(path):
|
||||
if path=='/user':
|
||||
users[0]+=1
|
||||
if users[0]==2:self.rb.source['title']='Changed during second capture'
|
||||
return old(path)
|
||||
self.rb.request=request
|
||||
with self.assertRaisesRegex(ValueError,'concurrent provider drift'):self.snapshot()
|
||||
self.assertEqual(users[0],2)
|
||||
|
||||
def test_timezone_and_nanosecond_order_is_exact(self):
|
||||
import copy
|
||||
rows=copy.deepcopy(self.rows);rows[1].update(state='REQUEST_CHANGES',body=self.code_body('request_changes'),submitted_at='2026-01-01T00:00:00.000000001Z',updated_at='2026-01-01T00:00:00.000000001Z')
|
||||
later=copy.deepcopy(self.rows[1]);later.update(id=303,submitted_at='2026-01-01T01:00:00.000000002+01:00',updated_at='2026-01-01T01:00:00.000000002+01:00',html_url=self.rb.pr['html_url']+'#pullreview-303')
|
||||
self.set_rows([later,*rows]);receipt=self.snapshot();self.assertEqual(receipt['status'],'no_op');self.assertEqual(receipt['decision']['selected_review_ids']['gitea-review-agent'],303)
|
||||
|
||||
def test_real_public_cli_runs_gate_without_selected_review(self):
|
||||
import hashlib,json,sys
|
||||
from unittest.mock import patch as mock
|
||||
def load(name,path):
|
||||
spec=importlib.util.spec_from_file_location(name,path);mod=importlib.util.module_from_spec(spec);spec.loader.exec_module(mod);return mod
|
||||
cli=load('gate_cli_test',SCRIPT.with_name('rework_readback_cli.py'));command=load('gate_command_test',SCRIPT.with_name('rework_comment.py'))
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
root=Path(td);target=root/'target.json';provider=root/'provider.json';output=root/'decision.json'
|
||||
target.write_text(json.dumps(self.inp));provider.write_text(json.dumps(self.rb.contract));target.chmod(0o600);provider.chmod(0o600)
|
||||
args=['rework_comment.py','input-gate','--target',str(target),'--target-sha256',hashlib.sha256(target.read_bytes()).hexdigest(),'--provider-contract',str(provider),'--provider-contract-sha256',hashlib.sha256(provider.read_bytes()).hexdigest(),'--actor-id','42','--output',str(output)]
|
||||
with mock.dict(sys.modules,{'rework_readback_cli':cli}),mock.object(sys,'argv',args),mock.dict(os.environ,{'HERMES_HOME':td}),mock.object(cli,'load_role_token',return_value='PUBLIC'+'-fixture-value') as loader,mock.object(cli,'RestTransport',return_value=self.rb.request):
|
||||
self.assertEqual(command.main(),0);self.assertTrue(loader.called)
|
||||
self.assertEqual(json.loads(output.read_text())['status'],'no_op');self.assertEqual(output.stat().st_mode&0o777,0o600)
|
||||
import copy
|
||||
rows=copy.deepcopy(self.rows);rows[1].update(state='REQUEST_CHANGES',body=self.code_body('request_changes'))
|
||||
self.set_rows(list(reversed(rows)));output=root/'rework-decision.json';args[-1]=str(output)
|
||||
with mock.dict(sys.modules,{'rework_readback_cli':cli}),mock.object(sys,'argv',args),mock.dict(os.environ,{'HERMES_HOME':td}),mock.object(cli,'load_role_token',return_value='PUBLIC'+'-fixture-value'),mock.object(cli,'RestTransport',return_value=self.rb.request):
|
||||
self.assertEqual(command.main(),0)
|
||||
actual=json.loads(output.read_text());self.assertEqual(actual['status'],'rework');self.assertEqual(actual['decision']['rework_review_ids'],[302])
|
||||
saved=output.read_bytes()
|
||||
with mock.dict(sys.modules,{'rework_readback_cli':cli}),mock.object(sys,'argv',args),mock.object(cli,'load_role_token') as loader,mock.object(cli,'RestTransport') as transport:
|
||||
self.assertEqual(command.main(),2);loader.assert_not_called();transport.assert_not_called()
|
||||
self.assertEqual(output.read_bytes(),saved)
|
||||
|
||||
def test_initial_output_atomic_publish_preserves_race_created_file(self):
|
||||
from unittest.mock import patch as mock
|
||||
spec=importlib.util.spec_from_file_location('no_clobber_test',SCRIPT.with_name('rework_readback_cli.py'));cli=importlib.util.module_from_spec(spec);spec.loader.exec_module(cli)
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
output=Path(td)/'decision.json';original_link=cli.os.link
|
||||
with cli.OutputFile(output,initial_only=True) as writer:
|
||||
def race_link(*args,**kwargs):
|
||||
output.write_bytes(b'raced output');output.chmod(0o600);return original_link(*args,**kwargs)
|
||||
with mock.object(cli.os,'link',side_effect=race_link),self.assertRaisesRegex(ValueError,'created during capture'):writer.write({'decision':'bounded'})
|
||||
self.assertEqual(output.read_bytes(),b'raced output');self.assertEqual(output.stat().st_mode&0o777,0o600)
|
||||
self.assertEqual([x.name for x in Path(td).iterdir()],['decision.json'])
|
||||
|
||||
def test_initial_output_total_byte_budget_rejects_without_artifact(self):
|
||||
spec=importlib.util.spec_from_file_location('size_gate_test',SCRIPT.with_name('rework_readback_cli.py'));cli=importlib.util.module_from_spec(spec);spec.loader.exec_module(cli)
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
output=Path(td)/'decision.json'
|
||||
with cli.OutputFile(output,initial_only=True) as writer,self.assertRaisesRegex(ValueError,'output size'):writer.write({'raw':'x'*1_000_001})
|
||||
self.assertFalse(output.exists())
|
||||
|
||||
def test_old_head_state_contradiction_is_history_only(self):
|
||||
import copy
|
||||
self.assertEqual(self.snapshot()['status'],'no_op')
|
||||
old=copy.deepcopy(self.rows[1]);old.update(id=303,commit_id='a'*40,
|
||||
body=self.code_body('request_changes').replace(self.inp['head'],'a'*40),
|
||||
html_url=self.rb.pr['html_url']+'#pullreview-303')
|
||||
self.set_rows([old,*self.rows]);receipt=self.snapshot()
|
||||
self.assertEqual(receipt['status'],'no_op',receipt['reason_codes'])
|
||||
history=next(x for x in receipt['decision']['timeline'] if x['id']==303)
|
||||
self.assertIn('old_head',history['reason_codes']);self.assertFalse(history['eligible'])
|
||||
|
||||
def test_same_instant_body_conflict_has_no_selected_review(self):
|
||||
import copy
|
||||
self.assertEqual(self.snapshot()['status'],'no_op')
|
||||
other=copy.deepcopy(self.rows[1]);other.update(id=303,body=other['body']+'Different finding text.\n',html_url=self.rb.pr['html_url']+'#pullreview-303')
|
||||
self.set_rows([other,*self.rows]);receipt=self.snapshot()
|
||||
self.assertEqual(receipt['status'],'blocked');self.assertIn('conflicting_same_time_reviews',receipt['reason_codes'])
|
||||
self.assertIsNone(receipt['decision']['selected_review_ids']['gitea-review-agent'])
|
||||
|
||||
def test_ambiguous_code_marker_or_verdict_lines_block(self):
|
||||
import copy
|
||||
self.assertEqual(self.snapshot()['status'],'no_op')
|
||||
for suffix in ('- Verdict: malformed\n','Quoted marker: '+self.code_body('approve').splitlines()[0]+'\n'):
|
||||
rows=copy.deepcopy(self.rows);rows[1]['body']+=suffix;self.set_rows(rows)
|
||||
with self.subTest(suffix=suffix):self.assertEqual(self.snapshot()['status'],'blocked')
|
||||
|
||||
def test_invalid_timezone_minute_is_rejected(self):
|
||||
import copy
|
||||
self.assertEqual(self.snapshot()['status'],'no_op')
|
||||
rows=copy.deepcopy(self.rows);rows[1]['submitted_at']='2026-01-01T00:00:00+00:99';self.set_rows(rows)
|
||||
with self.assertRaisesRegex(ValueError,'timestamp|submitted_at'):self.snapshot()
|
||||
|
||||
def test_rework_targets_exclude_spec_approvals(self):
|
||||
import copy
|
||||
self.assertEqual(self.snapshot()['status'],'no_op')
|
||||
rows=copy.deepcopy(self.rows);rows[1].update(state='REQUEST_CHANGES',body=self.code_body('request_changes'))
|
||||
self.set_rows(rows);receipt=self.snapshot();self.assertEqual(receipt['status'],'rework')
|
||||
self.assertEqual(receipt['decision'].get('rework_review_ids'),[302])
|
||||
|
||||
def test_cli_preflight_rejects_bool_target_before_credentials(self):
|
||||
import hashlib,json,sys,copy
|
||||
from unittest.mock import patch as mock
|
||||
spec=importlib.util.spec_from_file_location('cli_preflight_test',SCRIPT.with_name('rework_readback_cli.py'));cli=importlib.util.module_from_spec(spec);spec.loader.exec_module(cli)
|
||||
spec=importlib.util.spec_from_file_location('cmd_preflight_test',SCRIPT.with_name('rework_comment.py'));cmd=importlib.util.module_from_spec(spec);spec.loader.exec_module(cmd)
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
root=Path(td);target=root/'target.json';provider=root/'provider.json';output=root/'decision.json'
|
||||
bad=copy.deepcopy(self.inp);bad['target']['issue']=True;target.write_text(json.dumps(bad));provider.write_text(json.dumps(self.rb.contract));target.chmod(0o600);provider.chmod(0o600)
|
||||
args=['command','input-gate','--target',str(target),'--target-sha256',hashlib.sha256(target.read_bytes()).hexdigest(),'--provider-contract',str(provider),'--provider-contract-sha256',hashlib.sha256(provider.read_bytes()).hexdigest(),'--actor-id','42','--output',str(output)]
|
||||
with mock.dict(sys.modules,{'rework_readback_cli':cli}),mock.object(sys,'argv',args),mock.object(cli,'load_role_token') as loader,mock.object(cli,'RestTransport') as transport:
|
||||
self.assertEqual(cmd.main(),2);loader.assert_not_called();transport.assert_not_called()
|
||||
self.assertFalse(output.exists())
|
||||
|
||||
def test_cli_capture_race_preserves_foreign_output(self):
|
||||
import hashlib,json,sys
|
||||
from unittest.mock import patch as mock
|
||||
spec=importlib.util.spec_from_file_location('cli_race_test',SCRIPT.with_name('rework_readback_cli.py'));cli=importlib.util.module_from_spec(spec);spec.loader.exec_module(cli)
|
||||
spec=importlib.util.spec_from_file_location('cmd_race_test',SCRIPT.with_name('rework_comment.py'));cmd=importlib.util.module_from_spec(spec);spec.loader.exec_module(cmd)
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
root=Path(td);target=root/'target.json';provider=root/'provider.json';output=root/'decision.json'
|
||||
target.write_text(json.dumps(self.inp));provider.write_text(json.dumps(self.rb.contract));target.chmod(0o600);provider.chmod(0o600)
|
||||
args=['command','input-gate','--target',str(target),'--target-sha256',hashlib.sha256(target.read_bytes()).hexdigest(),'--provider-contract',str(provider),'--provider-contract-sha256',hashlib.sha256(provider.read_bytes()).hexdigest(),'--actor-id','42','--output',str(output)]
|
||||
requests=[]
|
||||
def transport(path):
|
||||
requests.append(path)
|
||||
if len(requests)==1:output.write_bytes(b'created concurrently');output.chmod(0o600)
|
||||
return self.rb.request(path)
|
||||
with mock.dict(sys.modules,{'rework_readback_cli':cli}),mock.object(sys,'argv',args),mock.dict(os.environ,{'HERMES_HOME':td}),mock.object(cli,'load_role_token',return_value='PUBLIC'+'-fixture-value'),mock.object(cli,'RestTransport',return_value=transport):
|
||||
self.assertEqual(cmd.main(),2)
|
||||
self.assertGreater(len(requests),1);self.assertEqual(output.read_bytes(),b'created concurrently');self.assertEqual(output.stat().st_mode&0o777,0o600)
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user