478 lines
27 KiB
Python
478 lines
27 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
|
|
|
|
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_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
|
|
# Each load has a distinct exception class; assert ValueError at the public boundary.
|
|
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,[],{'X-Total-Count':'0'},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)
|
|
# Prove this exact clean fixture is admitted before adversarial mutation.
|
|
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):
|
|
# Pinned primary serializers: services/convert/pull.go and modules/structs/pull.go (v1.24.6).
|
|
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')
|