Files
gitea-agent/harness/tests/impl/test_rework_readback.py
T

708 lines
41 KiB
Python

import copy
import hashlib
import importlib.util
import json
import tempfile
import unittest
from argparse import Namespace
from pathlib import Path
from urllib.parse import parse_qs, urlsplit
from harness.tests.common.impl_contract_docs import read_contract
DIRECTORY = Path(__file__).parents[2] / 'agents/impl/skills/gitea-issue-rework/scripts'
ORIGIN = 'https://gitea.jsfamily.quest'
T0 = '2026-01-01T00:00:00Z'
T1 = '2026-01-02T00:00:00Z'
class ReworkReadbackTests(unittest.TestCase):
def load(self, name):
path = DIRECTORY / (name + '.py')
self.assertTrue(path.is_file(), 'canonical readback core missing')
spec = importlib.util.spec_from_file_location(name, path)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def setUp(self):
self.legacy = self.load('rework_comment')
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
root = Path(self.temp.name)
data = {'owner':'acme','repository':'demo','issue':7,'pr':9,'review':301,
'reviewed_head':'a'*40,'final_head':'b'*40,'contract_sha256':'c'*64,
'result':'partial','findings':[{'id':'F-1','status':'resolved','classification':'valid_fix',
'changes':'bounded fix','verification':'local checks pass','reason':'regression'}],
'local_checks':'pass','runtime_browser':'not applicable',
'commit_status':'success','actions_runs':'없음'}
(root/'input.json').write_text(json.dumps(data))
self.legacy.do_render(Namespace(input=str(root/'input.json'), no_delegated_evidence=True,
delegation_adoption=None, comment=str(root/'comment.md'), expected=str(root/'expected.json')))
self.expected = json.loads((root/'expected.json').read_text())
self.contract = {'schema':'gitea-agent.rework-provider/v1','version':'1.24.6',
'aggregate':{'pr':{'field':'comments','components':['top_level','reviews','inline']},
'pr_issue':{'field':'comments','components':['top_level','reviews','inline']}},
'inline':{'mode':'per_review','field':'review_comments','components':['reviews','inline']}}
self.repo = {'id':3,'full_name':'acme/demo','html_url':ORIGIN+'/acme/demo'}
self.source = {'id':707,'number':7,'html_url':ORIGIN+'/acme/demo/issues/7',
'body':'source contract','title':'Source','state':'open','comments':0,'updated_at':T0}
self.pr = {'id':99,'number':9,'html_url':ORIGIN+'/acme/demo/pulls/9',
'url':ORIGIN+'/acme/demo/pulls/9','state':'open',
'head':{'sha':'b'*40,'repo':{'id':12,'full_name':'contributor/demo'}},
'base':{'repo':copy.deepcopy(self.repo)},'title':'PR','body':'Closes #7',
'comments':1,'review_comments':1,'updated_at':T0}
self.underlying = {'id':99,'number':9,'html_url':self.pr['html_url'],
'pull_request':{'html_url':self.pr['html_url'],'merged':False},
'title':'PR','body':'Closes #7','state':'open','comments':1,'updated_at':T0}
self.reviews = [{'id':301,'commit_id':'a'*40,'comments_count':0,'state':'REQUEST_CHANGES',
'user':{'id':33,'login':'gitea-review-agent'}}]
self.comments = []
self.source_comments = []
self.inline = []
self.overrides = {}
self.calls = []
for record in (self.source, self.pr, self.underlying):
record.update(created_at=T0, labels=[], assignees=[], milestone=None,
user={'id':22,'login':'genichin'} if record is self.source
else {'id':42,'login':'gitea-impl-agent'})
self.pr['base'].update(ref='main', label='main', sha='d'*40, repo_id=3)
self.pr['head'].update(ref='fix/issue-7', label='contributor:fix/issue-7', repo_id=12)
self.reviews[0].update(body='Fix F-1', submitted_at=T0, updated_at=T0,
stale=True, dismissed=False, html_url=self.pr['html_url']+'#pullreview-301',
pull_request_url=self.pr['html_url'])
def request(self, path):
self.calls.append(path)
if path in self.overrides: return copy.deepcopy(self.overrides[path])
parsed = urlsplit(path)
objects = {'/user':{'id':42,'login':'gitea-impl-agent'}, '/version':{'version':'1.24.6'},
'/repos/acme/demo':self.repo, '/repos/acme/demo/issues/7':self.source,
'/repos/acme/demo/pulls/9':self.pr, '/repos/acme/demo/issues/9':self.underlying}
if parsed.path in objects: return copy.deepcopy(objects[parsed.path]), {}, 200
collections = {'/repos/acme/demo/issues/7/comments':self.source_comments,
'/repos/acme/demo/issues/9/comments':self.comments,
'/repos/acme/demo/pulls/9/reviews':self.reviews,
'/repos/acme/demo/pulls/9/reviews/301/comments':self.inline}
if parsed.path not in collections: raise AssertionError('unexpected GET endpoint')
rows = collections[parsed.path]
page = int(parse_qs(parsed.query)['page'][0])
return copy.deepcopy(rows[(page-1)*50:page*50]), {'X-Total-Count':str(len(rows))}, 200
def adapter(self):
module = self.load('rework_readback')
try:
return module.ReworkReadbackAdapter(self.request, origin=ORIGIN+'/api/v1',
credential_source='$HERMES_HOME/.env:GITEA_TOKEN', actor_id=42,
provider_contract=self.contract)
except Exception as error:
self.fail('canonical CLI transport contract rejected: '+str(error))
def add_result(self):
self.comments.append({'id':501,'html_url':self.pr['html_url']+'#issuecomment-501',
'body':self.expected['body'],'created_at':T1,'updated_at':T1,
'user':{'id':42,'login':'gitea-impl-agent'}})
self.pr['comments'] += 1
self.underlying['comments'] += 1
self.pr['updated_at'] = self.underlying['updated_at'] = T1
def test_review_only_projection_preserves_inline_records_without_counting_them(self):
module = self.load('rework_readback')
contract = copy.deepcopy(self.contract)
contract['inline']['components'] = ['reviews']
adapter = module.ReworkReadbackAdapter(self.request, origin=ORIGIN+'/api/v1',
credential_source='$HERMES_HOME/.env:GITEA_TOKEN', actor_id=42, provider_contract=contract)
self.pr['review_comments'] = 1
self.reviews[0]['comments_count'] = 0
snapshot = adapter.snapshot(self.expected)
self.assertEqual(snapshot['normalized_counts']['inline'], 0)
self.reviews[0]['comments_count'] = 1
self.inline = [{'id':801,'body':'inline','created_at':T0,'updated_at':T0,
'user':{'id':33,'login':'gitea-review-agent'}}]
self.pr['review_comments'] = 1
self.pr['comments'] = self.underlying['comments'] = 2
snapshot = adapter.snapshot(self.expected)
self.assertEqual(snapshot['normalized_counts']['inline'], 1)
def test_review_only_projection_rejects_counter_mismatch(self):
module = self.load('rework_readback')
contract = copy.deepcopy(self.contract)
contract['inline']['components'] = ['reviews']
adapter = module.ReworkReadbackAdapter(self.request, origin=ORIGIN+'/api/v1',
credential_source='$HERMES_HOME/.env:GITEA_TOKEN', actor_id=42, provider_contract=contract)
self.reviews[0]['comments_count'] = 1
self.inline = [{'id':801,'body':'inline','created_at':T0,'updated_at':T0,
'user':{'id':33,'login':'gitea-review-agent'}}]
self.pr['review_comments'] = 0
with self.assertRaisesRegex(ValueError, 'inline provider counter crosscheck failed'):
adapter.snapshot(self.expected)
def test_inline_component_contract_validation_remains_strict(self):
module = self.load('rework_readback')
for parts in ([], None, 'reviews', ['inline','inline'], ['reviews','reviews'],
['unknown'], ['reviews','unknown'], ['reviews','inline','reviews']):
contract = copy.deepcopy(self.contract)
contract['inline']['components'] = parts
with self.subTest(components=parts), self.assertRaises(ValueError):
module.ReworkReadbackAdapter(self.request, origin=ORIGIN+'/api/v1',
credential_source='$HERMES_HOME/.env:GITEA_TOKEN', actor_id=42, provider_contract=contract)
def test_review_only_projection_retains_inline_evidence_rejections(self):
module = self.load('rework_readback')
contract = copy.deepcopy(self.contract)
contract['inline']['components'] = ['reviews']
adapter = module.ReworkReadbackAdapter(self.request, origin=ORIGIN+'/api/v1',
credential_source='$HERMES_HOME/.env:GITEA_TOKEN', actor_id=42, provider_contract=contract)
self.reviews[0]['comments_count'] = 1
self.inline = [{'id':801,'body':'inline','created_at':T0,'updated_at':T0,
'user':{'id':33,'login':'gitea-review-agent'}}]
self.pr['review_comments'] = 2
with self.assertRaises(ValueError): adapter.snapshot(self.expected)
self.pr['review_comments'] = 1
self.inline = []
with self.assertRaisesRegex(ValueError, 'per-review inline count mismatch'):
adapter.snapshot(self.expected)
self.inline = [{'id':801,'body':'inline','created_at':T0,'updated_at':T0,
'user':{'id':33,'login':'gitea-review-agent'}},
{'id':801,'body':'duplicate','created_at':T0,'updated_at':T0,
'user':{'id':33,'login':'gitea-review-agent'}}]
with self.assertRaisesRegex(ValueError, 'duplicate'):
adapter.snapshot(self.expected)
self.inline = [{'id':801,'body':'inline','created_at':T0,'updated_at':T0,
'user':{'id':33,'login':'gitea-review-agent'}}]
self.overrides = {'/repos/acme/demo/pulls/9/reviews/301/comments?page=1&limit=50':
(self.inline, {'X-Total-Count':'2'}, 200)}
with self.assertRaises(ValueError): adapter.snapshot(self.expected)
def test_snapshot_rejects_wrong_provider_bindings(self):
cases = [('repo',['id'],True), ('repo',['full_name'],'elsewhere/demo'),
('repo',['html_url'],'https://foreign.invalid/acme/demo'),
('source',['number'],8), ('source',['id'],True),
('source',['html_url'],ORIGIN+'/elsewhere/demo/issues/7'),
('source',['pull_request'],{}), ('pr',['number'],10), ('pr',['id'],True),
('pr',['url'],'https://foreign.invalid/api/v1/repos/acme/demo/pulls/9'),
('pr',['html_url'],ORIGIN+'/elsewhere/demo/pulls/9'),
('pr',['head','sha'],'c'*40), ('pr',['base','repo','id'],4),
('pr',['state'],'closed'), ('underlying',['number'],10),
('underlying',['pull_request'],True), ('reviews',[0,'commit_id'],'c'*40)]
error = self.load('rework_readback').ReadbackError
self.assertTrue(issubclass(error, ValueError))
for attribute, keys, value in cases:
with self.subTest(attribute=attribute, keys=keys):
original = copy.deepcopy(getattr(self, attribute))
item = getattr(self, attribute)
for key in keys[:-1]: item = item[key]
item[keys[-1]] = value
try:
with self.assertRaises(ValueError): self.adapter().snapshot(self.expected)
finally: setattr(self, attribute, original)
def test_pagination_complete_51_and_rejects_bad_pages(self):
self.comments = [{'id':600+i,'body':'existing '+str(i),'created_at':T0,'updated_at':T0,
'user':{'id':8,'login':'other'}} for i in range(51)]
self.pr['comments'] = self.underlying['comments'] = 52
snapshot = self.adapter().snapshot(self.expected)
self.assertEqual(snapshot['normalized_counts']['top_level'], 51)
self.assertEqual(len(snapshot['raw']['comments']['pages']), 2)
first = '/repos/acme/demo/issues/9/comments?page=1&limit=50'
second = '/repos/acme/demo/issues/9/comments?page=2&limit=50'
invalid = [(second,[self.comments[0]],{'X-Total-Count':'51'},200),
(second,[self.comments[-1]],{'X-Total-Count':'52'},200),
(first,None,{},200), (first,[],{'X-Total-Count':'0','x-total-count':'0'},200),
(first,[],{'X-Total-Count':'0','Link':'<https://foreign.invalid/comments?page=2&limit=50>; rel="next"'},200),
(first,self.comments[:50],{},200),
(first,[{'id':True}],{'X-Total-Count':'1'},200),
(first,[],{'X-Total-Count':'0','Link':'not a Link'},200),
(first,[],{},True), (first,[],{},403), (first,[],{},302)]
for path, rows, headers, status in invalid:
with self.subTest(path=path, headers=headers, status=status):
self.overrides = {path:(rows,headers,status)}
with self.assertRaises(ValueError): self.adapter().snapshot(self.expected)
self.overrides = {}
def test_counts_only_inline_fallback_is_explicit_and_crosschecked(self):
self.contract['inline']['mode'] = 'per_review_or_pr_count'
self.reviews[0]['comments_count'] = 3
self.pr['comments'] = self.underlying['comments'] = self.pr['review_comments'] = 4
endpoint = '/repos/acme/demo/pulls/9/reviews/301/comments?page=1&limit=50'
self.overrides = {endpoint:(None,{},404)}
try: snapshot = self.adapter().snapshot(self.expected)
except ValueError as error: self.fail('configured counts-only fallback missing: '+str(error))
self.assertEqual(snapshot['normalized_counts']['inline'], 3)
self.assertEqual(snapshot['fallback'], 'counts_only')
self.assertEqual(snapshot['raw']['inline']['301']['rows'], [])
self.pr['review_comments'] = 5
with self.assertRaises(ValueError): self.adapter().snapshot(self.expected)
self.pr['review_comments'] = 4
for status, payload in ((403,None),(302,None),(200,None)):
self.overrides = {endpoint:(payload,{},status)}
with self.subTest(status=status):
with self.assertRaises(ValueError): self.adapter().snapshot(self.expected)
self.contract['inline']['mode'] = 'per_review'
self.overrides = {endpoint:(None,{},404)}
with self.assertRaises(ValueError): self.adapter().snapshot(self.expected)
def test_preflight_rejects_unbound_contract_and_target_before_GET(self):
module = self.load('rework_readback')
changes = [(['schema'],'unsupported'), (['version'],True),
(['aggregate','pr','components'],['inline']),
(['aggregate','pr','components'],['top_level','top_level']),
(['aggregate','pr_issue','field'],'other'),
(['inline','mode'],'automatic'), (['inline','components'],['other'])]
for keys, value in changes:
contract = copy.deepcopy(self.contract)
item = contract
for key in keys[:-1]: item = item[key]
item[keys[-1]] = value
with self.subTest(contract=keys):
with self.assertRaises(ValueError):
module.ReworkReadbackAdapter(self.request, origin=ORIGIN+'/api/v1',
credential_source='$HERMES_HOME/.env:GITEA_TOKEN', actor_id=42,
provider_contract=contract)
self.assertEqual(self.calls, [])
for keys, value in ((['target','owner'],'acme/../elsewhere'),
(['target','issue'],9), (['review'],True), (['head'],'B'*40),
(['body'],'forged body')):
expected = copy.deepcopy(self.expected)
item = expected
for key in keys[:-1]: item = item[key]
item[keys[-1]] = value
self.calls = []
with self.subTest(expected=keys):
with self.assertRaises(ValueError): self.adapter().snapshot(expected)
self.assertEqual(self.calls, [], 'invalid expectation reached provider')
def test_snapshot_detects_drift_during_collection(self):
original = self.request
endpoints = ['/user','/version','/repos/acme/demo','/repos/acme/demo/issues/7',
'/repos/acme/demo/pulls/9','/repos/acme/demo/issues/9',
'/repos/acme/demo/pulls/9/reviews']
for endpoint in endpoints:
counts = {}
def changing(path):
value, headers, status = original(path)
key = urlsplit(path).path
counts[key] = counts.get(key, 0) + 1
if key == endpoint and counts[key] > 1:
if type(value) is list: value[0]['state'] = 'APPROVED'
else: value['unexpected_drift'] = True
return value, headers, status
self.request = changing
try:
with self.subTest(endpoint=endpoint):
with self.assertRaisesRegex(ValueError, 'drift'):
self.adapter().snapshot(self.expected)
finally: self.request = original
def test_normalization_rejects_unrelated_delta_and_forged_pages(self):
adapter = self.adapter()
baseline = adapter.snapshot(self.expected)
self.add_result()
after = adapter.snapshot(self.expected)
adapter.normalized_inputs(self.expected, baseline, after, 'create')
cases = [(['schema'],'forged'), (['actor_id'],True), (['origin'],'https://foreign.invalid/api/v1'),
(['target','owner'],'other'), (['provider','version'],'different'),
(['expected_sha256'],'0'*64), (['raw','identity','login'],'other'),
(['raw','source_issue','body'],'unrelated edit'),
(['raw','reviews','rows',0,'state'],'APPROVED'),
(['raw','pr','head','sha'],'c'*40), (['raw','pr','title'],'unrelated edit'),
(['raw','pr','updated_at'],'2026-01-03T00:00:00Z'),
(['raw','comments','pages',0,'path'],'/repos/elsewhere/demo/issues/9/comments?page=1&limit=50'),
(['raw','comments','pages',0,'count'],0),
(['raw','comments','rows',0,'body'],'forged body'),
(['raw','comments','rows',0,'user','id'],8),
(['raw','comments','rows',0,'created_at'],'2025-01-01T00:00:00Z'),
(['raw','comments','rows',0,'updated_at'],'2026-01-03T00:00:00Z')]
for keys, value in cases:
changed = copy.deepcopy(after)
item = changed
for key in keys[:-1]: item = item[key]
item[keys[-1]] = value
with self.subTest(keys=keys):
with self.assertRaises(ValueError):
adapter.normalized_inputs(self.expected, baseline, changed, 'create')
with self.assertRaises(ValueError): adapter.normalized_inputs(self.expected, baseline, after, 'reuse')
def test_existing_comment_rows_are_immutable(self):
self.comments = [{'id':400,'body':'prior comment','created_at':T0,'updated_at':T0,
'user':{'id':8,'login':'other'}}]
self.pr['comments'] = self.underlying['comments'] = 2
adapter = self.adapter()
baseline = adapter.snapshot(self.expected)
self.add_result()
after = adapter.snapshot(self.expected)
adapter.normalized_inputs(self.expected, baseline, after, 'create')
after['raw']['comments']['rows'][0]['body'] = 'changed prior comment'
with self.assertRaisesRegex(ValueError, 'existing comment'):
adapter.normalized_inputs(self.expected, baseline, after, 'create')
def test_snapshot_validates_captured_rows_before_persisting(self):
self.add_result()
self.adapter().snapshot(self.expected)
for keys, value in ((['body'],True), (['user','id'],True),
(['created_at'],'2026-01-02T00:00:00'),
(['updated_at'],T0)):
saved = copy.deepcopy(self.comments)
item = self.comments[0]
for key in keys[:-1]: item = item[key]
item[keys[-1]] = value
with self.subTest(keys=keys):
with self.assertRaises(ValueError): self.adapter().snapshot(self.expected)
self.comments = saved
self.source['updated_at'] = 'not a timestamp'
with self.assertRaises(ValueError): self.adapter().snapshot(self.expected)
def test_live_bridge_round_trip_over_local_HTTP(self):
import contextlib
import http.server
import io
import os
import threading
import urllib.request
from unittest.mock import patch
cli = self.load('rework_readback_cli')
root = Path(self.temp.name)
profile = root/'profile'
profile.mkdir(mode=0o700)
secret = 'synthetic-fixture-' + 'q'*40
(profile/'.env').write_text('GITEA_TOKEN='+secret+'\n')
(profile/'.env').chmod(0o600)
(root/'provider.json').write_text(json.dumps(self.contract))
contract_hash = hashlib.sha256((root/'provider.json').read_bytes()).hexdigest()
requests = []
fixture = self
class Handler(http.server.BaseHTTPRequestHandler):
def log_message(self, format, *args): pass
def do_GET(self):
requests.append((self.command, self.headers.get('Authorization') == 'token '+secret))
data, headers, status = fixture.request(self.path[len('/api/v1'):])
self.send_response(status)
self.send_header('Content-Type','application/json')
for key, value in headers.items(): self.send_header(key,value)
self.end_headers()
self.wfile.write(json.dumps(data).encode())
server = http.server.ThreadingHTTPServer(('127.0.0.1',0), Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
inner = urllib.request.build_opener(cli.NoRedirect())
class Proxy:
def open(self, request, timeout):
forwarded = urllib.request.Request('http://127.0.0.1:'+str(server.server_port)+request.selector,
headers=dict(request.header_items()), method=request.get_method())
response = inner.open(forwarded, timeout=timeout)
class Response:
status = response.status
headers = response.headers
def geturl(self): return request.full_url
def read1(self, n): return response.read1(n)
def __enter__(self): return self
def __exit__(self, *args): response.close()
return Response()
transport = cli.RestTransport
common = dict(expected=str(root/'expected.json'), provider_contract=str(root/'provider.json'),
provider_contract_sha256=contract_hash, actor_id=42)
baseline_path, receipt_path = root/'baseline.json', root/'receipt.json'
try:
with patch.dict(os.environ, {'HERMES_HOME':str(profile)}), patch.object(cli,'RestTransport',
side_effect=lambda credential: transport(credential, opener=Proxy())), contextlib.redirect_stdout(io.StringIO()) as stdout:
cli.run_live(Namespace(cmd='snapshot', output=str(baseline_path), **common), self.legacy.verify_readbacks)
baseline_hash = hashlib.sha256(baseline_path.read_bytes()).hexdigest()
self.add_result()
args = Namespace(cmd='verify-live', baseline=str(baseline_path), baseline_sha256=baseline_hash,
action='create', output=str(receipt_path), **common)
cli.run_live(args, self.legacy.verify_readbacks)
receipt = json.loads(receipt_path.read_text())
self.assertEqual(receipt['status'],'verified')
self.assertEqual(receipt['comment_id'],501)
self.assertEqual(receipt['readback_provenance']['kind'],'fresh_authenticated_GET')
original = receipt_path.read_bytes()
self.source['title'] = 'unrelated provider edit'
with self.assertRaises(ValueError): cli.run_live(args, self.legacy.verify_readbacks)
self.assertEqual(receipt_path.read_bytes(), original)
self.assertNotIn(secret, stdout.getvalue())
self.assertNotIn(secret, receipt_path.read_text())
self.assertTrue(requests)
self.assertEqual(set(method for method, _ in requests), {'GET'})
self.assertTrue(all(authenticated for _, authenticated in requests))
finally:
server.shutdown()
server.server_close()
thread.join(timeout=3)
def test_underlying_relation_requires_native_html_url(self):
self.adapter().snapshot(self.expected)
self.underlying['pull_request'].pop('html_url')
with self.assertRaises(ValueError): self.adapter().snapshot(self.expected)
def test_non_next_Link_relations_are_coherent_and_preserved(self):
endpoint = '/repos/acme/demo/issues/7/comments'
path = endpoint+'?page=1&limit=50'
for relation, page in (('last',21), ('prev',1), ('first',2)):
self.overrides = {path:([],{'X-Total-Count':'0','Link':
'</api/v1'+endpoint+'?page='+str(page)+'&limit=50>; rel="'+relation+'"'},200)}
with self.subTest(relation=relation):
with self.assertRaises(ValueError): self.adapter().snapshot(self.expected)
header = '</api/v1'+endpoint+'?page=1&limit=50>; rel="last"'
self.overrides = {path:([],{'X-Total-Count':'0','Link':header},200)}
snapshot = self.adapter().snapshot(self.expected)
self.assertEqual(snapshot['raw']['source_comments']['pages'][0].get('link'), header)
def test_pagination_link_rejects_unbalanced_relation_quotes(self):
endpoint = '/repos/acme/demo/issues/7/comments'
path = endpoint+'?page=1&limit=50'
for relation in ('"last', 'last"'):
self.overrides = {path:([],{'X-Total-Count':'0','Link':
'</api/v1'+endpoint+'?page=1&limit=50>; rel='+relation},200)}
with self.subTest(relation=relation):
with self.assertRaises(ValueError): self.adapter().snapshot(self.expected)
def test_native_Gitea_PR_URL_and_omitted_proven_zero_inline_count(self):
self.pr['url'] = self.pr['html_url']
self.contract['inline']['components'] = ['inline']
self.pr.pop('review_comments')
try: snapshot = self.adapter().snapshot(self.expected)
except ValueError as error: self.fail('native Gitea wire shape rejected: '+str(error))
self.assertEqual(snapshot['normalized_counts']['inline'], 0)
self.assertEqual(snapshot['fallback'], 'none')
self.contract['inline']['mode'] = 'per_review_or_pr_count'
self.overrides = {'/repos/acme/demo/pulls/9/reviews/301/comments?page=1&limit=50':(None,{},404)}
with self.assertRaises(ValueError): self.adapter().snapshot(self.expected)
def test_boolean_actor_is_rejected_before_other_GETs(self):
module = self.load('rework_readback')
self.overrides = {'/user':({'id':True,'login':'gitea-impl-agent'}, {}, 200)}
adapter = module.ReworkReadbackAdapter(self.request, origin=ORIGIN+'/api/v1',
credential_source='$HERMES_HOME/.env:GITEA_TOKEN', actor_id=1, provider_contract=self.contract)
with self.assertRaises(ValueError): adapter.snapshot(self.expected)
self.assertEqual(self.calls, ['/user'])
def test_required_native_metadata_and_PR_author_are_verified(self):
self.adapter().snapshot(self.expected)
cases = [('pr',['user'],None), ('pr',['user','id'],43),
('pr',['user','login'],'different'), ('source',['labels'],True),
('source',['assignees'],True), ('source',['milestone'],[]),
('pr',['base','ref'],None), ('pr',['head','repo_id'],99),
('reviews',[0,'state'],None), ('reviews',[0,'body'],None),
('reviews',[0,'user','login'],'unapproved'),
('reviews',[0,'submitted_at'],None), ('reviews',[0,'dismissed'],True),
('reviews',[0,'stale'],'true')]
for name, keys, value in cases:
original = copy.deepcopy(getattr(self,name))
item = getattr(self,name)
for key in keys[:-1]: item = item[key]
item[keys[-1]] = value
with self.subTest(name=name, keys=keys):
with self.assertRaises(ValueError): self.adapter().snapshot(self.expected)
setattr(self,name,original)
for name, field in (('source','labels'), ('source','assignees'), ('source','milestone'),
('underlying','user'), ('reviews','submitted_at')):
original = copy.deepcopy(getattr(self,name))
record = getattr(self,name)[0] if name == 'reviews' else getattr(self,name)
record.pop(field)
with self.subTest(missing=name+'.'+field):
with self.assertRaises(ValueError): self.adapter().snapshot(self.expected)
setattr(self,name,original)
def test_source_timestamp_change_is_not_admitted_as_causal_proof(self):
adapter = self.adapter()
baseline = adapter.snapshot(self.expected)
self.add_result()
after = adapter.snapshot(self.expected)
adapter.normalized_inputs(self.expected, baseline, after, 'create')
after['raw']['source_issue']['updated_at'] = T1
with self.assertRaises(ValueError): adapter.normalized_inputs(self.expected, baseline, after, 'create')
def test_native_nullable_assignees_and_milestone_without_number(self):
for record in (self.source,self.pr,self.underlying):
record['assignees'] = None
record['milestone'] = {'id':55,'title':'Native milestone','state':'open'}
try: self.adapter().snapshot(self.expected)
except ValueError as error: self.fail('native nullable-assignee/milestone shape rejected: '+str(error))
self.source.pop('assignees')
with self.assertRaises(ValueError): self.adapter().snapshot(self.expected)
def test_closing_keyword_must_match_canonical_Closes(self):
self.adapter().snapshot(self.expected)
self.pr['body'] = 'Fixes #7'
with self.assertRaises(ValueError): self.adapter().snapshot(self.expected)
def test_renderer_to_existing_verifier_create_and_reuse(self):
adapter = self.adapter()
try: baseline = adapter.snapshot(self.expected)
except Exception as error: self.fail('actual renderer expected rejected: '+str(error))
self.assertEqual(self.calls[0], '/user')
self.add_result()
after = adapter.snapshot(self.expected)
inputs = adapter.normalized_inputs(self.expected, baseline, after, 'create')
receipt = self.legacy.verify_readbacks(self.expected, inputs['pr'], inputs['comments'],
inputs['identity'], action='create', baseline_count=inputs['baseline_count'])
self.assertEqual(receipt['status'], 'verified')
self.assertEqual(receipt['comment_id'], 501)
reused = adapter.normalized_inputs(self.expected, after, adapter.snapshot(self.expected), 'reuse')
self.assertEqual(self.legacy.verify_readbacks(self.expected, reused['pr'], reused['comments'],
reused['identity'], action='reuse', baseline_count=reused['baseline_count'])['status'], 'verified')
def _cache_adapter(self):
self.contract['version'] = '1.27.3'
self.overrides['/version'] = ({'version':'1.27.3'}, {}, 200)
self.contract['aggregate']['pr'] = {'field':'comments','components':['top_level','reviews'],
'cache_mode':'gitea-conversation-v1'}
self.contract['aggregate']['pr_issue'] = {'field':'comments','components':['top_level','reviews'],
'cache_mode':'gitea-conversation-v1'}
return self.adapter()
def _stale_baseline(self):
self.pr['comments'] = self.underlying['comments'] = 0
adapter = self._cache_adapter()
return adapter, adapter.snapshot(self.expected)
def test_cache_opt_in_captures_and_validates_bounded_stale_baseline(self):
adapter, baseline = self._stale_baseline()
self.assertEqual(baseline['normalized_counts']['top_level'], 0)
self.assertEqual(baseline['normalized_counts']['reviews'], 1)
self.assertEqual(baseline['raw']['pr']['comments'], 0)
self.assertEqual(baseline['raw']['pr_issue']['comments'], 0)
adapter.validate_snapshot(self.expected, baseline)
self.overrides['/version'] = ({'version':'1.24.6'}, {}, 200)
with self.assertRaisesRegex(ValueError, 'provider version mismatch'):
adapter.snapshot(self.expected)
def test_cache_create_recomputes_post_conversation_count_and_guards_deltas(self):
adapter, baseline = self._stale_baseline()
self.add_result()
self.pr['comments'] = self.underlying['comments'] = 2
after = adapter.snapshot(self.expected)
inputs = adapter.normalized_inputs(self.expected, baseline, after, 'create')
receipt = self.legacy.verify_readbacks(self.expected, inputs['pr'], inputs['comments'],
inputs['identity'], action='create', baseline_count=inputs['baseline_count'])
self.assertEqual(receipt['status'], 'verified')
# Capture a genuine pre-create synchronized baseline, not the post-create rows.
self.comments.clear()
self.pr['comments'] = self.underlying['comments'] = 1
self.pr['updated_at'] = self.underlying['updated_at'] = T0
synchronized = adapter.snapshot(self.expected)
self.add_result()
synchronized_after = adapter.snapshot(self.expected)
adapter.normalized_inputs(self.expected, synchronized, synchronized_after, 'create')
for bad in (1, None, -1, 3):
changed = copy.deepcopy(after)
changed['raw']['pr']['comments'] = bad
changed['raw']['pr_issue']['comments'] = bad
with self.subTest(after_counter=bad), self.assertRaises(ValueError):
adapter.normalized_inputs(self.expected, baseline, changed, 'create')
for mutation in ('old_comment','duplicate','native_review','source_issue'):
changed = copy.deepcopy(after)
if mutation == 'old_comment':
changed['raw']['comments']['rows'] = [{'id':400,'body':'mutated','created_at':T0,
'updated_at':T0,'user':{'id':8,'login':'other'}}] + changed['raw']['comments']['rows']
elif mutation == 'duplicate':
changed['raw']['comments']['rows'].append(copy.deepcopy(changed['raw']['comments']['rows'][0]))
elif mutation == 'native_review': changed['raw']['reviews']['rows'][0]['state'] = 'APPROVED'
else: changed['raw']['source_issue']['title'] = 'drift'
with self.subTest(mutation=mutation), self.assertRaises(ValueError):
adapter.normalized_inputs(self.expected, baseline, changed, 'create')
def test_cache_reuse_allows_same_lag_but_rejects_refresh(self):
self.add_result()
self.pr['comments'] = self.underlying['comments'] = 0
adapter = self._cache_adapter()
baseline = adapter.snapshot(self.expected)
same = adapter.snapshot(self.expected)
inputs = adapter.normalized_inputs(self.expected, baseline, same, 'reuse')
receipt = self.legacy.verify_readbacks(self.expected, inputs['pr'], inputs['comments'],
inputs['identity'], action='reuse', baseline_count=inputs['baseline_count'])
self.assertEqual(receipt['status'], 'verified')
self.pr['comments'] = self.underlying['comments'] = 2
refreshed = adapter.snapshot(self.expected)
with self.assertRaises(ValueError): adapter.normalized_inputs(self.expected, baseline, refreshed, 'reuse')
def test_cache_opt_in_rejects_invalid_mode_version_and_components(self):
module = self.load('rework_readback')
for mode, version, components in (
('unknown','1.27.3',['top_level','reviews']),
(None,'1.27.3',['top_level','reviews']),
(True,'1.27.3',['top_level','reviews']),
('gitea-conversation-v1','1.27.2',['top_level','reviews']),
('gitea-conversation-v1','1.27.3',['reviews']),
('gitea-conversation-v1','1.27.3',['top_level']),
('gitea-conversation-v1','1.27.3',['top_level','reviews','inline'])):
contract = copy.deepcopy(self.contract)
contract['version'] = version
contract['aggregate']['pr'] = {'field':'comments','components':components,'cache_mode':mode}
contract['aggregate']['pr_issue'] = {'field':'comments','components':components,'cache_mode':mode}
with self.subTest(mode=mode,version=version,components=components), self.assertRaises(ValueError):
module.ReworkReadbackAdapter(self.request, origin=ORIGIN+'/api/v1',
credential_source='$HERMES_HOME/.env:GITEA_TOKEN', actor_id=42, provider_contract=contract)
def test_strict_legacy_counter_semantics_are_unchanged_without_cache_mode(self):
self.pr['comments'] = self.underlying['comments'] = 0
with self.assertRaises(ValueError): self.adapter().snapshot(self.expected)
def test_cache_baseline_does_not_count_inline_records_as_conversation(self):
adapter = self._cache_adapter()
self.contract['inline']['components'] = ['reviews']
adapter = self.adapter()
self.reviews[0]['comments_count'] = 1
self.inline = [{'id':801,'body':'inline','created_at':T0,'updated_at':T0,
'user':{'id':33,'login':'gitea-review-agent'}}]
self.pr['comments'] = self.underlying['comments'] = 2
with self.assertRaisesRegex(ValueError, 'cached provider aggregate count exceeds collection'):
adapter.snapshot(self.expected)
def test_cache_baseline_rejects_malformed_missing_and_ahead_counters(self):
adapter = self._cache_adapter()
for scope in (self.pr, self.underlying):
for bad in (None, -1, True, '0', 1.0, 2):
scope['comments'] = bad
with self.subTest(counter=bad), self.assertRaises(ValueError):
adapter.snapshot(self.expected)
scope.pop('comments')
with self.assertRaises(ValueError): adapter.snapshot(self.expected)
scope['comments'] = 1
def test_cache_transition_rejects_mutation_of_a_genuine_existing_comment(self):
old = {'id':400, 'html_url':self.pr['html_url']+'#issuecomment-400',
'body':'preserved previous comment', 'created_at':T0, 'updated_at':T0,
'user':{'id':33,'login':'gitea-review-agent'}}
self.comments.append(old)
adapter, baseline = self._stale_baseline()
self.add_result()
self.pr['comments'] = self.underlying['comments'] = 3
after = adapter.snapshot(self.expected)
adapter.normalized_inputs(self.expected, baseline, after, 'create')
changed = copy.deepcopy(after)
changed['raw']['comments']['rows'][0]['body'] = 'unauthorized mutation'
with self.assertRaisesRegex(ValueError, 'existing comment changed or missing'):
adapter.normalized_inputs(self.expected, baseline, changed, 'create')
def test_cache_opt_in_does_not_relax_an_undeclared_aggregate_scope(self):
self._cache_adapter()
self.contract['aggregate']['pr_issue'].pop('cache_mode')
adapter = self.adapter()
self.pr['comments'] = self.underlying['comments'] = 0
with self.assertRaisesRegex(ValueError, 'provider aggregate count mismatch'):
adapter.snapshot(self.expected)
self.underlying['comments'] = 1
adapter.snapshot(self.expected)
def test_cache_provider_documentation_example_matches_executable_contract(self):
import re
doc = (DIRECTORY.parent/'references/readback.md').read_text()
match = re.search(r'```json\s*(.*?)\s*```', doc, re.S)
if match is None: self.fail('provider documentation requires a JSON example')
example = json.loads(match.group(1))
self._cache_adapter()
self.contract = example
self.adapter().snapshot(self.expected)
def test_shared_commit_message_safety_rule_is_linked_and_amend_is_narrow(self):
common = (DIRECTORY.parents[4]/'operations/repository-cache.md').read_text()
skill = read_contract(DIRECTORY.parent / 'SKILL.md')
self.assertIn('## Commit-message reference safety', common)
for clause in ('complete subject and body', 'cross-repository',
'non-default branch', 'explicit user authorization',
'commit is unpublished', 'tree is identical',
'PR body', 'not general permission to amend'):
self.assertIn(clause, common)
self.assertIn('repository-cache.md#commit-message-reference-safety', skill)