diff --git a/harness/agents/impl/skills/gitea-issue-rework/SKILL.md b/harness/agents/impl/skills/gitea-issue-rework/SKILL.md index 96188ae..3725e77 100644 --- a/harness/agents/impl/skills/gitea-issue-rework/SKILL.md +++ b/harness/agents/impl/skills/gitea-issue-rework/SKILL.md @@ -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 diff --git a/harness/agents/impl/skills/gitea-issue-rework/references/input-gate.md b/harness/agents/impl/skills/gitea-issue-rework/references/input-gate.md new file mode 100644 index 0000000..1ef9efb --- /dev/null +++ b/harness/agents/impl/skills/gitea-issue-rework/references/input-gate.md @@ -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 --target-sha256 \ + --provider-contract \ + --provider-contract-sha256 --actor-id \ + --output /decision.json +``` + +The canonical output is `/repositories///worktrees/issue-/verification//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. diff --git a/harness/agents/impl/skills/gitea-issue-rework/scripts/rework_comment.py b/harness/agents/impl/skills/gitea-issue-rework/scripts/rework_comment.py index f3f5ffc..6a14010 100755 --- a/harness/agents/impl/skills/gitea-issue-rework/scripts/rework_comment.py +++ b/harness/agents/impl/skills/gitea-issue-rework/scripts/rework_comment.py @@ -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) diff --git a/harness/agents/impl/skills/gitea-issue-rework/scripts/rework_input_gate.py b/harness/agents/impl/skills/gitea-issue-rework/scripts/rework_input_gate.py new file mode 100644 index 0000000..f6d9f77 --- /dev/null +++ b/harness/agents/impl/skills/gitea-issue-rework/scripts/rework_input_gate.py @@ -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'') + 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'') + 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] != ['']: + 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',''), '\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 '\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()