test(harness): reorganize regression suites and preserve contract coverage
This commit is contained in:
@@ -0,0 +1,259 @@
|
||||
import hashlib, json, subprocess, sys, tempfile, unittest
|
||||
from pathlib import Path
|
||||
|
||||
from harness.tests._support.paths import ROOT
|
||||
|
||||
SCRIPT = ROOT / "harness/agents/impl/skills/gitea-issue-rework/scripts/rework_comment.py"
|
||||
|
||||
class ReworkCommentTests(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.t = tempfile.TemporaryDirectory()
|
||||
self.d = Path(self.t.name)
|
||||
self.data = {'owner': 'acme', 'repository': 'widget', 'issue': 7, 'pr': 12, 'review': 99, 'reviewed_head': 'a' * 40, 'final_head': 'b' * 40, 'contract_sha256': 'c' * 64, 'result': 'partial', 'findings': [{'id': 'F-1', 'status': 'resolved', 'classification': 'valid_fix', 'changes': 'src/main.py', 'verification': 'pytest -q (0), pass', 'reason': 'bounded fix'}], 'local_checks': 'pytest -q (0), pass', 'runtime_browser': '해당 없음·미검증', 'commit_status': 'success', 'actions_runs': '없음'}
|
||||
self.write('input.json', self.data)
|
||||
self.invoke('render', '--no-delegated-evidence', '--input', self.p('input.json'), '--comment', self.p('comment.md'), '--expected', self.p('expected.json'))
|
||||
self.expected = json.loads(self.d.joinpath('expected.json').read_text())
|
||||
|
||||
def tearDown(self):
|
||||
self.t.cleanup()
|
||||
|
||||
def p(self, n):
|
||||
return str(self.d / n)
|
||||
|
||||
def write(self, n, x):
|
||||
self.d.joinpath(n).write_text(json.dumps(x, ensure_ascii=False) if not isinstance(x, str) else x)
|
||||
|
||||
def invoke(self, *args):
|
||||
return subprocess.run([sys.executable, str(SCRIPT), *args], text=True, capture_output=True)
|
||||
|
||||
def read(self, n):
|
||||
return json.loads(self.d.joinpath(n).read_text())
|
||||
|
||||
def readback(self, comments=None, head=None):
|
||||
self.write('pr.json', {'object_kind': 'pull_request', 'owner': 'acme', 'repository': 'widget', 'number': 12, 'state': 'open', 'head': {'sha': head or self.data['final_head']}, 'html_url': 'https://gitea/acme/widget/pulls/12', 'metadata_binding_verified': True})
|
||||
self.write('identity.json', {'id': 42, 'login': 'bot', 'role': 'gitea-impl-agent', 'authenticated': True, 'stable_account_verified': True})
|
||||
items = comments if comments is not None else [{'id': 501, 'html_url': 'https://gitea/x/501', 'created_at': '2026-01-01T00:00:00Z', 'updated_at': '2026-01-01T00:00:00Z', 'body': self.expected['body'], 'user': {'id': 42, 'login': 'bot'}}]
|
||||
self.write('comments.json', {'object_kind': 'pull_request_top_level_comments', 'owner': 'acme', 'repository': 'widget', 'pr': 12, 'complete': True, 'endpoint_binding_verified': True, 'source_issue_unchanged': True, 'review_collection_unchanged': True, 'pr_metadata_unchanged': True, 'page_count': 1, 'total_count': len(items), 'provider_counts': {'top_level': len(items), 'reviews': 2, 'inline': 3, 'aggregate_comments': len(items), 'aggregate_components': ['top_level']}, 'comments': items})
|
||||
|
||||
def verify(self, action='create', baseline=0):
|
||||
return self.invoke('verify', '--expected', self.p('expected.json'), '--pr-readback', self.p('pr.json'), '--comments-readback', self.p('comments.json'), '--identity-readback', self.p('identity.json'), '--action', action, '--baseline-count', str(baseline), '--output', self.p('receipt.json'))
|
||||
|
||||
def test_render_digest_and_correlation(self):
|
||||
self.assertEqual(hashlib.sha256(self.expected['marker_free_body'].encode()).hexdigest(), self.expected['result_sha256'])
|
||||
parts = ['acme', 'widget', '7', '12', '99', 'a' * 40, 'b' * 40, 'c' * 64]
|
||||
self.assertEqual(hashlib.sha256('\x00'.join(parts).encode()).hexdigest(), self.expected['correlation'])
|
||||
self.assertTrue(self.d.joinpath('comment.md').read_text().endswith('-->\n'))
|
||||
|
||||
def test_no_comment_and_marker_body_mismatch(self):
|
||||
self.readback([])
|
||||
self.assertNotEqual(self.verify().returncode, 0)
|
||||
self.readback([{'id': 1, 'body': self.expected['body'].replace('correlation=', 'correlation=x'), 'user': {'id': 42, 'login': 'bot'}}])
|
||||
self.assertNotEqual(self.verify().returncode, 0)
|
||||
|
||||
def test_wrong_head_and_author(self):
|
||||
self.readback(head='d' * 40)
|
||||
self.assertNotEqual(self.verify().returncode, 0)
|
||||
self.readback()
|
||||
c = self.read('comments.json')
|
||||
c['comments'][0]['user'] = {'id': 43, 'login': 'bot'}
|
||||
self.write('comments.json', c)
|
||||
self.assertNotEqual(self.verify().returncode, 0)
|
||||
|
||||
def test_digest_and_correlation_tampering_duplicate(self):
|
||||
self.expected['result_sha256'] = '0' * 64
|
||||
self.write('expected.json', self.expected)
|
||||
self.readback()
|
||||
self.assertNotEqual(self.verify().returncode, 0)
|
||||
self.setUp_render_again()
|
||||
self.expected['correlation'] = '0' * 64
|
||||
self.write('expected.json', self.expected)
|
||||
self.readback()
|
||||
self.assertNotEqual(self.verify().returncode, 0)
|
||||
self.setUp_render_again()
|
||||
self.readback()
|
||||
c = self.read('comments.json')
|
||||
c['comments'].append(dict(c['comments'][0], id=502))
|
||||
c['total_count'] = 2
|
||||
self.write('comments.json', c)
|
||||
self.assertNotEqual(self.verify('reuse', 1).returncode, 0)
|
||||
|
||||
def setUp_render_again(self):
|
||||
self.invoke('render', '--no-delegated-evidence', '--input', self.p('input.json'), '--comment', self.p('comment.md'), '--expected', self.p('expected.json'))
|
||||
self.expected = self.read('expected.json')
|
||||
|
||||
def test_create_reuse_cardinality(self):
|
||||
self.readback()
|
||||
self.assertEqual(self.verify('create', 0).returncode, 0)
|
||||
self.assertEqual(self.read('receipt.json')['status'], 'verified')
|
||||
original = self.read('comments.json')['comments'][0]
|
||||
self.readback([original, {'id': 2, 'html_url': 'u', 'created_at': 't', 'updated_at': 't', 'body': 'other', 'user': {'id': 42, 'login': 'bot'}}])
|
||||
self.assertEqual(self.verify('reuse', 2).returncode, 0)
|
||||
|
||||
def test_exact_success_and_receipt_preserved(self):
|
||||
self.readback()
|
||||
self.assertEqual(self.verify().returncode, 0)
|
||||
old = self.d.joinpath('receipt.json').read_bytes()
|
||||
self.readback(head='e' * 40)
|
||||
self.assertNotEqual(self.verify().returncode, 0)
|
||||
self.assertEqual(old, self.d.joinpath('receipt.json').read_bytes())
|
||||
|
||||
def test_unresolved_delivery_and_incomplete_or_wrong_scope_readback_fail(self):
|
||||
bad = dict(self.data)
|
||||
bad['result'] = 'rework_delivered'
|
||||
bad['findings'] = [dict(self.data['findings'][0], status='unresolved')]
|
||||
self.write('bad-input.json', bad)
|
||||
self.assertNotEqual(self.invoke('render', '--no-delegated-evidence', '--input', self.p('bad-input.json'), '--comment', self.p('bad.md'), '--expected', self.p('bad.json')).returncode, 0)
|
||||
self.readback()
|
||||
comments = self.read('comments.json')
|
||||
comments['complete'] = False
|
||||
self.write('comments.json', comments)
|
||||
self.assertNotEqual(self.verify().returncode, 0)
|
||||
self.readback()
|
||||
pr = self.read('pr.json')
|
||||
pr['repository'] = 'other'
|
||||
self.write('pr.json', pr)
|
||||
self.assertNotEqual(self.verify().returncode, 0)
|
||||
|
||||
def test_impl_role_and_provider_aggregate_binding_are_required(self):
|
||||
self.readback()
|
||||
identity = self.read('identity.json')
|
||||
identity['role'] = 'other'
|
||||
self.write('identity.json', identity)
|
||||
self.assertNotEqual(self.verify().returncode, 0)
|
||||
self.readback()
|
||||
comments = self.read('comments.json')
|
||||
comments['provider_counts']['aggregate_comments'] += 1
|
||||
self.write('comments.json', comments)
|
||||
self.assertNotEqual(self.verify().returncode, 0)
|
||||
|
||||
def test_render_visible_identifier_lines_do_not_emit_issue_references(self):
|
||||
body = self.expected['marker_free_body']
|
||||
self.assertIn('- Source Issue: 7', body)
|
||||
self.assertIn('- Pull Request: 12', body)
|
||||
self.assertIn('- Source Review: 99', body)
|
||||
self.assertNotIn('- Source Issue: #7', body)
|
||||
self.assertNotIn('- Pull Request: #12', body)
|
||||
self.assertNotIn('- Source Review: #99', body)
|
||||
|
||||
def test_render_rejects_reference_hazards_in_every_safe_human_field(self):
|
||||
hazards = ('#123', 'otherrepo#123', '\\\\#123', '\\#123', 'review #99')
|
||||
fields = ('changes', 'verification', 'reason', 'local_checks', 'runtime_browser')
|
||||
for field in fields:
|
||||
for snippet in hazards:
|
||||
with self.subTest(field=field, snippet=snippet):
|
||||
data = json.loads(json.dumps(self.data))
|
||||
if field in ('changes', 'verification', 'reason'):
|
||||
data['findings'][0][field] = 'unsafe payload ' + snippet
|
||||
else:
|
||||
data[field] = 'unsafe payload ' + snippet
|
||||
self.write('hazard.json', data)
|
||||
result = self.invoke('render', '--no-delegated-evidence', '--input', self.p('hazard.json'), '--comment', self.p('hazard.md'), '--expected', self.p('hazard-expected.json'))
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
|
||||
def test_offline_verifier_rejects_reference_in_expected_body(self):
|
||||
self.expected['marker_free_body'] += '\n- unsafe: otherrepo#123'
|
||||
before_hash = self.expected['result_sha256']
|
||||
self.expected['result_sha256'] = hashlib.sha256(self.expected['marker_free_body'].encode()).hexdigest()
|
||||
self.expected['marker'] = self.expected['marker'].replace(before_hash, self.expected['result_sha256'])
|
||||
self.expected['body'] = self.expected['marker_free_body'] + self.expected['marker']
|
||||
self.write('expected.json', self.expected)
|
||||
self.readback()
|
||||
self.assertNotEqual(self.verify().returncode, 0)
|
||||
|
||||
def _reseal(self, value, body):
|
||||
value = json.loads(json.dumps(value))
|
||||
old = value['result_sha256']
|
||||
value['marker_free_body'] = body
|
||||
value['result_sha256'] = hashlib.sha256(body.encode()).hexdigest()
|
||||
value['marker'] = value['marker'].replace(old, value['result_sha256'])
|
||||
value['body'] = body + value['marker']
|
||||
return value
|
||||
|
||||
def _legacy_expected(self):
|
||||
old = json.loads(json.dumps(self.expected))
|
||||
body = old['marker_free_body']
|
||||
body = body.replace('- Source Issue: #7', '- Source Issue: 7').replace('- Pull Request: #12', '- Pull Request: 12').replace('- Source Review: #99', '- Source Review: 99')
|
||||
body = body.replace('- Source Issue: 7', '- Source Issue: #7').replace('- Pull Request: 12', '- Pull Request: #12').replace('- Source Review: 99', '- Source Review: #99')
|
||||
old['marker_free_body'] = body
|
||||
old['result_sha256'] = hashlib.sha256(body.encode()).hexdigest()
|
||||
old['marker'] = old['marker'].replace(self.expected['result_sha256'], old['result_sha256'])
|
||||
old['body'] = body + old['marker']
|
||||
return old
|
||||
|
||||
def test_prepare_correction_returns_bounded_verified_legacy_migration_plan(self):
|
||||
import importlib.util
|
||||
spec = importlib.util.spec_from_file_location('rework_comment_correction_test', SCRIPT)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
self.assertTrue(callable(getattr(module, 'prepare_correction', None)), 'approved correction API missing')
|
||||
plan = module.prepare_correction(self._legacy_expected(), self.expected, comment_id=501, actor_id=42)
|
||||
self.assertEqual(plan['schema'], 'gitea-rework-correction-plan/v1')
|
||||
self.assertEqual(plan['target'], self.expected['target'])
|
||||
self.assertEqual(plan['comment_id'], 501)
|
||||
self.assertEqual(plan['actor_id'], 42)
|
||||
self.assertEqual(plan['old_body'], self._legacy_expected()['body'])
|
||||
self.assertEqual(plan['new_body'], self.expected['body'])
|
||||
self.assertEqual(plan['old_body_sha256'], hashlib.sha256(plan['old_body'].encode()).hexdigest())
|
||||
self.assertEqual(plan['new_body_sha256'], hashlib.sha256(plan['new_body'].encode()).hexdigest())
|
||||
self.assertIs(plan['prior_handoff_verified'], False)
|
||||
self.assertTrue(plan['immutable_source_required'])
|
||||
|
||||
def test_prepare_correction_rejects_tampered_or_unbounded_changes(self):
|
||||
import importlib.util
|
||||
spec = importlib.util.spec_from_file_location('rework_comment_correction_reject_test', SCRIPT)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
self.assertTrue(callable(getattr(module, 'prepare_correction', None)), 'approved correction API missing')
|
||||
old = self._legacy_expected()
|
||||
cases = []
|
||||
bad = json.loads(json.dumps(old))
|
||||
bad['result_sha256'] = '0' * 64
|
||||
cases.append((bad, self.expected))
|
||||
bad = json.loads(json.dumps(old))
|
||||
bad['body'] = bad['body'].replace('#7', '#8', 1)
|
||||
cases.append((bad, self.expected))
|
||||
bad = json.loads(json.dumps(old))
|
||||
bad['marker_free_body'] = bad['marker_free_body'].replace('bounded fix', 'unsafe old text #123')
|
||||
cases.append((bad, self.expected))
|
||||
bad = json.loads(json.dumps(self.expected))
|
||||
bad['marker_free_body'] = bad['marker_free_body'].replace('bounded fix', 'different extra text')
|
||||
cases.append((old, bad))
|
||||
bad = self._reseal(self.expected, self.expected['marker_free_body'].replace('bounded fix', 'different extra text'))
|
||||
cases.append((old, bad))
|
||||
unsafe_old = self._reseal(old, old['marker_free_body'].replace('bounded fix', 'same unsafe #123'))
|
||||
unsafe_new = self._reseal(self.expected, self.expected['marker_free_body'].replace('bounded fix', 'same unsafe #123'))
|
||||
cases.append((unsafe_old, unsafe_new))
|
||||
bad = json.loads(json.dumps(self.expected))
|
||||
bad['head'] = 'd' * 40
|
||||
cases.append((old, bad))
|
||||
bad = json.loads(json.dumps(self.expected))
|
||||
bad['correlation'] = 'e' * 64
|
||||
cases.append((old, bad))
|
||||
for previous, new in cases:
|
||||
with self.subTest(previous=previous['result_sha256'], new=new['correlation']):
|
||||
with self.assertRaises(module.Bad):
|
||||
module.prepare_correction(previous, new, comment_id=501, actor_id=42)
|
||||
|
||||
def test_prepare_correction_cli_emits_plan_not_approval_or_receipt(self):
|
||||
self.write('previous.json', self._legacy_expected())
|
||||
r = self.invoke('prepare-correction', '--previous-expected', self.p('previous.json'), '--expected', self.p('expected.json'), '--comment-id', '501', '--actor-id', '42', '--output', self.p('plan.json'))
|
||||
self.assertEqual(r.returncode, 0, r.stderr)
|
||||
plan = self.read('plan.json')
|
||||
self.assertEqual(plan['schema'], 'gitea-rework-correction-plan/v1')
|
||||
self.assertIs(plan['approval_required'], True)
|
||||
self.assertIs(plan['prior_handoff_verified'], False)
|
||||
self.assertNotEqual(plan.get('status'), 'verified')
|
||||
self.assertEqual(self.d.joinpath('plan.json').stat().st_mode & 511, 384)
|
||||
|
||||
def test_prepare_correction_cli_refuses_input_alias_and_preserves_file(self):
|
||||
self.write('previous.json', self._legacy_expected())
|
||||
before = self.d.joinpath('expected.json').read_bytes()
|
||||
r = self.invoke('prepare-correction', '--previous-expected', self.p('previous.json'), '--expected', self.p('expected.json'), '--comment-id', '501', '--actor-id', '42', '--output', self.p('expected.json'))
|
||||
self.assertNotEqual(r.returncode, 0)
|
||||
self.assertEqual(self.d.joinpath('expected.json').read_bytes(), before)
|
||||
|
||||
def test_different_body_same_correlation_is_not_default_reuse(self):
|
||||
old = self._legacy_expected()
|
||||
self.readback([{'id': 501, 'html_url': 'https://gitea/x/501', 'created_at': '2026-01-01T00:00:00Z', 'updated_at': '2026-01-01T00:00:00Z', 'body': old['body'], 'user': {'id': 42, 'login': 'bot'}}])
|
||||
self.assertNotEqual(self.verify('reuse', 1).returncode, 0)
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Functional correction verification with provider-shaped synthetic readbacks."""
|
||||
import copy
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from harness.tests._support.rework import ReworkProviderFixture, ORIGIN, T1
|
||||
|
||||
from harness.tests._support.paths import ROOT
|
||||
|
||||
DIRECTORY = ROOT / 'harness/agents/impl/skills/gitea-issue-rework/scripts'
|
||||
spec = importlib.util.spec_from_file_location('correction_verifier', DIRECTORY/'rework_correction.py')
|
||||
assert spec is not None and spec.loader is not None
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
|
||||
class CorrectionVerifierTests(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
fixture = ReworkProviderFixture().setup()
|
||||
fixture.contract['version'] = '1.27.3'
|
||||
fixture.overrides['/version'] = ({'version': '1.27.3'}, {}, 200)
|
||||
self.addCleanup(fixture.cleanup)
|
||||
self.new = copy.deepcopy(fixture.expected)
|
||||
self.old = copy.deepcopy(self.new)
|
||||
free = self.new['marker_free_body']
|
||||
for label in ('Source Issue', 'Pull Request', 'Source Review'):
|
||||
free = free.replace('- ' + label + ': ', '- ' + label + ': #')
|
||||
digest = hashlib.sha256(free.encode()).hexdigest()
|
||||
marker = self.new['marker'].replace(self.new['result_sha256'], digest)
|
||||
self.old.update(body=free + marker, marker=marker, marker_free_body=free, result_sha256=digest)
|
||||
self.plan = fixture.legacy.prepare_correction(self.old, self.new, comment_id=501, actor_id=42)
|
||||
fixture.add_result()
|
||||
fixture.comments[0]['body'] = self.old['body']
|
||||
self.before = fixture.adapter().snapshot(self.new)
|
||||
fixture.comments[0]['body'] = self.new['body']
|
||||
fixture.comments[0]['updated_at'] = '2026-01-03T00:00:00Z'
|
||||
self.after = fixture.adapter().snapshot(self.new)
|
||||
event = {'id': 601, 'type': 'comment_ref', 'user': {'id': 42, 'login': 'gitea-impl-agent'}, 'created_at': T1, 'updated_at': T1, 'ref_action': 'none', 'issue_url': ORIGIN + '/acme/demo/issues/7', 'ref_issue': {'number': 9, 'html_url': ORIGIN + '/acme/demo/pulls/9'}, 'ref_comment': copy.deepcopy(self.before['raw']['comments']['rows'][0])}
|
||||
self.before['source_issue_timeline'] = self.timeline(event)
|
||||
changed = copy.deepcopy(event)
|
||||
changed.update(ref_action='neutered', updated_at='2026-01-03T00:00:00Z', ref_comment=copy.deepcopy(self.after['raw']['comments']['rows'][0]))
|
||||
self.after['source_issue_timeline'] = self.timeline(changed)
|
||||
self.approval = {'approved': True, 'operation': 'same_comment_correction', 'historical_failure_baseline_sha256': '1' * 64, 'old_expected_document': self.old, 'new_expected_document': self.new, 'timeline_profile': 'observed-comment-ref-neutralization', 'ordinary_patch': True, 'provider_version': self.before['provider']['version']}
|
||||
|
||||
def timeline(self, event):
|
||||
return {'complete': True, 'rows': [event], 'pages': [{'page': 1, 'count': 1, 'headers': {'X-Total-Count': '1'}}]}
|
||||
|
||||
def verify(self, profile='observed-comment-ref-neutralization'):
|
||||
return mod.verify_correction(self.plan, self.before, self.after, self.old['body'], self.new['body'], self.approval, approval_sha256=mod.sha(mod.canonical(self.approval)), historical_failure_sha256='1' * 64, profile=profile)
|
||||
|
||||
def test_exact_approved_reference_neutralization_passes(self):
|
||||
result = self.verify()
|
||||
self.assertEqual(result['status'], 'verified')
|
||||
self.assertEqual(result['changed_timeline_ids'], [601])
|
||||
self.assertEqual(result['historical_handoff_status'], 'partial_unchanged')
|
||||
|
||||
def test_strict_unchanged_timeline_passes(self):
|
||||
self.after['source_issue_timeline'] = copy.deepcopy(self.before['source_issue_timeline'])
|
||||
self.approval['timeline_profile'] = 'strict'
|
||||
self.assertEqual(self.verify('strict')['status'], 'verified')
|
||||
|
||||
def test_reference_profile_requires_separate_approval(self):
|
||||
self.approval['timeline_profile'] = 'strict'
|
||||
with self.assertRaises(mod.Bad):
|
||||
self.verify()
|
||||
|
||||
def test_source_and_cache_deltas_fail(self):
|
||||
for name, field in [('source_issue', 'title'), ('pr', 'comments'), ('pr_issue', 'comments')]:
|
||||
original = copy.deepcopy(self.after)
|
||||
self.after['raw'][name][field] = 99 if field == 'comments' else 'changed'
|
||||
with self.subTest(name=name), self.assertRaises(mod.Bad):
|
||||
self.verify()
|
||||
self.after = original
|
||||
|
||||
def test_unrelated_timeline_field_change_fails(self):
|
||||
self.after['source_issue_timeline']['rows'][0]['body'] = 'unexpected'
|
||||
with self.assertRaises(mod.Bad):
|
||||
self.verify()
|
||||
|
||||
def test_unrelated_null_timeline_field_is_still_a_mutation(self):
|
||||
self.after['source_issue_timeline']['rows'][0]['unexpected_field'] = None
|
||||
with self.assertRaises(mod.Bad):
|
||||
self.verify()
|
||||
|
||||
def test_duplicate_and_missing_timeline_rows_fail(self):
|
||||
original = copy.deepcopy(self.after)
|
||||
for rows in [[], self.after['source_issue_timeline']['rows'] * 2]:
|
||||
self.after = copy.deepcopy(original)
|
||||
self.after['source_issue_timeline']['rows'] = rows
|
||||
with self.subTest(rows=len(rows)), self.assertRaises(mod.Bad):
|
||||
self.verify()
|
||||
|
||||
def test_wrong_actor_head_and_extra_body_change_fail(self):
|
||||
for field, value in [('actor_id', 99), ('head', 'd' * 40), ('new_body', 'arbitrary')]:
|
||||
original = copy.deepcopy(self.plan)
|
||||
self.plan[field] = value
|
||||
with self.subTest(field=field), self.assertRaises(mod.Bad):
|
||||
self.verify()
|
||||
self.plan = original
|
||||
|
||||
def test_reference_timestamp_cannot_move_backwards(self):
|
||||
self.after['source_issue_timeline']['rows'][0]['updated_at'] = '2025-01-01T00:00:00Z'
|
||||
with self.assertRaises(mod.Bad):
|
||||
self.verify()
|
||||
|
||||
def test_real_cli_valid_inputs_and_no_overwrite(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
args = [sys.executable, '-B', str(DIRECTORY / 'rework_correction.py'), 'verify']
|
||||
values = {'plan': self.plan, 'before': self.before, 'after': self.after, 'old-comment': {'body': self.old['body']}, 'new-comment': {'body': self.new['body']}, 'approval': self.approval}
|
||||
for name, value in values.items():
|
||||
raw = json.dumps(value).encode()
|
||||
path = root / (name + '.json')
|
||||
path.write_bytes(raw)
|
||||
path.chmod(384)
|
||||
args.extend(['--' + name, str(path), '--' + name + '-sha256', hashlib.sha256(raw).hexdigest()])
|
||||
output = root / 'receipt.json'
|
||||
args.extend(['--historical-failure-sha256', '1' * 64, '--profile', 'observed-comment-ref-neutralization', '--output', str(output)])
|
||||
result = subprocess.run(args, capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
saved = output.read_bytes()
|
||||
again = subprocess.run(args, capture_output=True, text=True, timeout=10)
|
||||
self.assertNotEqual(again.returncode, 0)
|
||||
self.assertEqual(output.read_bytes(), saved)
|
||||
|
||||
def test_digest_tampering_is_rejected(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
path = Path(td) / 'data.json'
|
||||
path.write_text('{}')
|
||||
with self.assertRaises(mod.Bad):
|
||||
mod.load(path, '0' * 64)
|
||||
@@ -0,0 +1,214 @@
|
||||
import importlib.util
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from harness.tests._support.paths import ROOT
|
||||
|
||||
SCRIPT = ROOT / 'harness/agents/impl/skills/gitea-issue-rework/scripts/rework_input_gate.py'
|
||||
|
||||
class OrderedDecisionCliTests(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
import copy, hashlib
|
||||
from harness.tests._support.rework import ReworkProviderFixture
|
||||
from harness.tests._support import spec as spec_tests
|
||||
self.rb = ReworkProviderFixture().setup()
|
||||
self.addCleanup(self.rb.cleanup)
|
||||
self.rb.repo['default_branch'] = 'main'
|
||||
self.rb.source['body'] = '<!-- gitea-agent:discovery-draft/v2 -->\nSynthetic approved source contract.\n'
|
||||
self.inp = {'schema': 'gitea-agent.rework-input/v1', 'target': {'owner': 'acme', 'repository': 'demo', 'issue': 7, 'pr': 9}, 'head': 'b' * 40, 'contract_sha256': hashlib.sha256(self.rb.source['body'].encode()).hexdigest()}
|
||||
self.rows = []
|
||||
for role, rid, uid in [('gitea-spec-agent', 301, 33), ('gitea-review-agent', 302, 34)]:
|
||||
row = copy.deepcopy(self.rb.reviews[0])
|
||||
row.update(id=rid, commit_id=self.inp['head'], state='APPROVED', user={'id': uid, 'login': role}, stale=False, dismissed=False, html_url=self.rb.pr['html_url'] + '#pullreview-' + str(rid))
|
||||
if role == 'gitea-spec-agent':
|
||||
result = spec_tests.result()
|
||||
result['target'] = dict(self.inp['target'])
|
||||
result['target']['pull'] = result['target'].pop('pr')
|
||||
result['target']['head'] = self.inp['head']
|
||||
result['contract_sha256'] = self.inp['contract_sha256']
|
||||
row['body'] = spec_tests.mod.render_review(result, {'src/a.py', 'tests/test_a.py'}, declaration={'mode': 'none'})
|
||||
else:
|
||||
row['body'] = self.code_body('approve')
|
||||
self.rows.append(row)
|
||||
self.set_rows(self.rows)
|
||||
original = self.rb.request
|
||||
|
||||
def request(path):
|
||||
from urllib.parse import urlsplit
|
||||
import re
|
||||
if re.fullmatch('/repos/acme/demo/pulls/9/reviews/[1-9][0-9]*/comments', urlsplit(path).path):
|
||||
self.rb.calls.append(path)
|
||||
return ([], {'X-Total-Count': '0'}, 200)
|
||||
return original(path)
|
||||
self.rb.request = request
|
||||
|
||||
def code_body(self, verdict):
|
||||
return '<!-- gitea-agent:code-review/v1 owner=acme repository=demo issue=7 pr=9 head=' + self.inp['head'] + ' contract-sha256=' + self.inp['contract_sha256'] + ' -->\n- Verdict: `' + verdict + '`\nBounded native code-review fixture.\n'
|
||||
|
||||
def set_rows(self, rows):
|
||||
import copy
|
||||
self.rb.reviews = copy.deepcopy(rows)
|
||||
self.rb.pr['comments'] = self.rb.underlying['comments'] = self.rb.pr['review_comments'] = len(rows)
|
||||
|
||||
def snapshot(self):
|
||||
return self.rb.adapter().input_snapshot(self.inp)
|
||||
|
||||
def spec_body(self, verdict, head=None):
|
||||
from harness.tests._support import spec as spec_tests
|
||||
result = spec_tests.result(verdict)
|
||||
result['target'] = {'owner': 'acme', 'repository': 'demo', 'issue': 7, 'pull': 9, 'head': head or self.inp['head']}
|
||||
result['contract_sha256'] = self.inp['contract_sha256']
|
||||
if verdict == 'request_changes':
|
||||
result['criteria'][0].update(result='unsatisfied', reason='Bounded regression finding.')
|
||||
result['conclusion'] = 'Bounded correction required.'
|
||||
return spec_tests.mod.render_review(result, {'src/a.py', 'tests/test_a.py'}, declaration={'mode': 'none'})
|
||||
|
||||
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' * 1000001})
|
||||
except ValueError as exc:
|
||||
self.fail('legacy output below original 16MB budget must remain accepted: ' + str(exc))
|
||||
self.assertGreater(output.stat().st_size, 1000000)
|
||||
|
||||
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(384)
|
||||
provider.chmod(384)
|
||||
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 & 511, 384)
|
||||
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(384)
|
||||
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 & 511, 384)
|
||||
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' * 1000001})
|
||||
self.assertFalse(output.exists())
|
||||
|
||||
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(384)
|
||||
provider.chmod(384)
|
||||
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(384)
|
||||
provider.chmod(384)
|
||||
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(384)
|
||||
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 & 511, 384)
|
||||
@@ -0,0 +1,380 @@
|
||||
import importlib.util
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from harness.tests._support.paths import ROOT
|
||||
|
||||
SCRIPT = ROOT / 'harness/agents/impl/skills/gitea-issue-rework/scripts/rework_input_gate.py'
|
||||
|
||||
class OrderedDecisionTests(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
import copy, hashlib
|
||||
from harness.tests._support.rework import ReworkProviderFixture
|
||||
from harness.tests._support import spec as spec_tests
|
||||
self.rb = ReworkProviderFixture().setup()
|
||||
self.addCleanup(self.rb.cleanup)
|
||||
self.rb.repo['default_branch'] = 'main'
|
||||
self.rb.source['body'] = '<!-- gitea-agent:discovery-draft/v2 -->\nSynthetic approved source contract.\n'
|
||||
self.inp = {'schema': 'gitea-agent.rework-input/v1', 'target': {'owner': 'acme', 'repository': 'demo', 'issue': 7, 'pr': 9}, 'head': 'b' * 40, 'contract_sha256': hashlib.sha256(self.rb.source['body'].encode()).hexdigest()}
|
||||
self.rows = []
|
||||
for role, rid, uid in [('gitea-spec-agent', 301, 33), ('gitea-review-agent', 302, 34)]:
|
||||
row = copy.deepcopy(self.rb.reviews[0])
|
||||
row.update(id=rid, commit_id=self.inp['head'], state='APPROVED', user={'id': uid, 'login': role}, stale=False, dismissed=False, html_url=self.rb.pr['html_url'] + '#pullreview-' + str(rid))
|
||||
if role == 'gitea-spec-agent':
|
||||
result = spec_tests.result()
|
||||
result['target'] = dict(self.inp['target'])
|
||||
result['target']['pull'] = result['target'].pop('pr')
|
||||
result['target']['head'] = self.inp['head']
|
||||
result['contract_sha256'] = self.inp['contract_sha256']
|
||||
row['body'] = spec_tests.mod.render_review(result, {'src/a.py', 'tests/test_a.py'}, declaration={'mode': 'none'})
|
||||
else:
|
||||
row['body'] = self.code_body('approve')
|
||||
self.rows.append(row)
|
||||
self.set_rows(self.rows)
|
||||
original = self.rb.request
|
||||
|
||||
def request(path):
|
||||
from urllib.parse import urlsplit
|
||||
import re
|
||||
if re.fullmatch('/repos/acme/demo/pulls/9/reviews/[1-9][0-9]*/comments', urlsplit(path).path):
|
||||
self.rb.calls.append(path)
|
||||
return ([], {'X-Total-Count': '0'}, 200)
|
||||
return original(path)
|
||||
self.rb.request = request
|
||||
|
||||
def code_body(self, verdict):
|
||||
return '<!-- gitea-agent:code-review/v1 owner=acme repository=demo issue=7 pr=9 head=' + self.inp['head'] + ' contract-sha256=' + self.inp['contract_sha256'] + ' -->\n- Verdict: `' + verdict + '`\nBounded native code-review fixture.\n'
|
||||
|
||||
def set_rows(self, rows):
|
||||
import copy
|
||||
self.rb.reviews = copy.deepcopy(rows)
|
||||
self.rb.pr['comments'] = self.rb.underlying['comments'] = self.rb.pr['review_comments'] = len(rows)
|
||||
|
||||
def snapshot(self):
|
||||
return self.rb.adapter().input_snapshot(self.inp)
|
||||
|
||||
def spec_body(self, verdict, head=None):
|
||||
from harness.tests._support import spec as spec_tests
|
||||
result = spec_tests.result(verdict)
|
||||
result['target'] = {'owner': 'acme', 'repository': 'demo', 'issue': 7, 'pull': 9, 'head': head or self.inp['head']}
|
||||
result['contract_sha256'] = self.inp['contract_sha256']
|
||||
if verdict == 'request_changes':
|
||||
result['criteria'][0].update(result='unsatisfied', reason='Bounded regression finding.')
|
||||
result['conclusion'] = 'Bounded correction required.'
|
||||
return spec_tests.mod.render_review(result, {'src/a.py', 'tests/test_a.py'}, declaration={'mode': 'none'})
|
||||
|
||||
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_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_approvals_are_not_noop_eligible(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._support import spec 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_old_head_state_contradiction_is_history_only(self):
|
||||
import copy
|
||||
receipt = self.snapshot()
|
||||
self.assertEqual(receipt['status'], 'no_op', receipt['reason_codes'])
|
||||
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_lone_spec_request_changes_allows_rework(self):
|
||||
rows = [dict(self.rows[0], state='REQUEST_CHANGES', body=self.spec_body('request_changes'))]
|
||||
self.set_rows(rows)
|
||||
receipt = self.snapshot()
|
||||
self.assertEqual(receipt['status'], 'rework', receipt['reason_codes'])
|
||||
self.assertEqual(receipt['decision']['selected_review_ids']['gitea-spec-agent'], 301)
|
||||
self.assertIsNone(receipt['decision']['selected_review_ids']['gitea-review-agent'])
|
||||
self.assertEqual(receipt['decision']['rework_review_ids'], [301])
|
||||
self.assertIn('missing_current_review', receipt['reason_codes'])
|
||||
|
||||
def test_lone_code_request_changes_allows_rework(self):
|
||||
import copy
|
||||
row = copy.deepcopy(self.rows[1])
|
||||
row.update(state='REQUEST_CHANGES', body=self.code_body('request_changes'))
|
||||
self.set_rows([row])
|
||||
receipt = self.snapshot()
|
||||
self.assertEqual(receipt['status'], 'rework', receipt['reason_codes'])
|
||||
self.assertIsNone(receipt['decision']['selected_review_ids']['gitea-spec-agent'])
|
||||
self.assertEqual(receipt['decision']['selected_review_ids']['gitea-review-agent'], 302)
|
||||
self.assertEqual(receipt['decision']['rework_review_ids'], [302])
|
||||
self.assertIn('missing_current_review', receipt['reason_codes'])
|
||||
|
||||
def test_valid_request_changes_survives_invalid_counterpart_history(self):
|
||||
import copy
|
||||
for request_index in (0, 1):
|
||||
for excluded in ('stale', 'dismissed', 'old_head'):
|
||||
with self.subTest(request_role=request_index, excluded=excluded):
|
||||
rows = copy.deepcopy(self.rows)
|
||||
request = rows[request_index]
|
||||
request.update(state='REQUEST_CHANGES', body=self.spec_body('request_changes') if request_index == 0 else self.code_body('request_changes'))
|
||||
other = rows[1 - request_index]
|
||||
if excluded == 'old_head':
|
||||
other.update(commit_id='a' * 40, body=self.spec_body('approve', head='a' * 40) if request_index == 1 else self.code_body('approve').replace(self.inp['head'], 'a' * 40))
|
||||
else:
|
||||
other[excluded] = True
|
||||
self.set_rows(rows)
|
||||
receipt = self.snapshot()
|
||||
self.assertEqual(receipt['status'], 'rework', receipt['reason_codes'])
|
||||
self.assertEqual(receipt['decision']['rework_review_ids'], [request['id']])
|
||||
self.assertIsNone(receipt['decision']['selected_review_ids'][other['user']['login']])
|
||||
self.assertIn('missing_current_review', receipt['reason_codes'])
|
||||
|
||||
def test_excluded_lone_requests_cannot_authorize_rework(self):
|
||||
import copy
|
||||
for request_index in (0, 1):
|
||||
for excluded in ('stale', 'dismissed', 'old_head'):
|
||||
with self.subTest(request_role=request_index, excluded=excluded):
|
||||
request = copy.deepcopy(self.rows[request_index])
|
||||
head = 'a' * 40 if excluded == 'old_head' else self.inp['head']
|
||||
request.update(state='REQUEST_CHANGES', commit_id=head, body=self.spec_body('request_changes', head=head) if request_index == 0 else self.code_body('request_changes').replace(self.inp['head'], head))
|
||||
if excluded != 'old_head':
|
||||
request[excluded] = True
|
||||
self.set_rows([request])
|
||||
receipt = self.snapshot()
|
||||
self.assertEqual(receipt['status'], 'blocked')
|
||||
self.assertEqual(receipt['decision']['rework_review_ids'], [])
|
||||
|
||||
def test_superseded_request_with_missing_counterpart_stays_blocked(self):
|
||||
import copy
|
||||
request = copy.deepcopy(self.rows[1])
|
||||
request.update(state='REQUEST_CHANGES', body=self.code_body('request_changes'))
|
||||
approval = copy.deepcopy(self.rows[1])
|
||||
approval.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([request, approval])
|
||||
receipt = self.snapshot()
|
||||
self.assertEqual(receipt['status'], 'blocked', receipt['reason_codes'])
|
||||
self.assertNotEqual(receipt['status'], 'rework')
|
||||
self.assertNotEqual(receipt['status'], 'no_op')
|
||||
self.assertEqual(receipt['decision']['rework_review_ids'], [])
|
||||
|
||||
def test_true_blockers_override_valid_request_changes(self):
|
||||
import copy
|
||||
request = copy.deepcopy(self.rows[1])
|
||||
request.update(state='REQUEST_CHANGES', body=self.code_body('request_changes'))
|
||||
for state in ('COMMENT', 'PENDING'):
|
||||
with self.subTest(state=state):
|
||||
rows = copy.deepcopy(self.rows)
|
||||
rows[1] = copy.deepcopy(request)
|
||||
rows[0]['state'] = state
|
||||
self.set_rows(rows)
|
||||
receipt = self.snapshot()
|
||||
self.assertEqual(receipt['status'], 'blocked')
|
||||
self.assertIn('comment_manual_classification' if state == 'COMMENT' else 'pending_authoritative_review', receipt['reason_codes'])
|
||||
rows = copy.deepcopy(self.rows)
|
||||
rows[1] = request
|
||||
rows[0]['body'] = 'missing marker'
|
||||
self.set_rows(rows)
|
||||
self.assertEqual(self.snapshot()['status'], 'blocked')
|
||||
rows = copy.deepcopy(self.rows)
|
||||
rows[1] = request
|
||||
rows[0]['body'] = rows[0]['body'].replace(self.inp['contract_sha256'], 'f' * 64)
|
||||
self.set_rows(rows)
|
||||
self.assertEqual(self.snapshot()['status'], 'blocked')
|
||||
rows = copy.deepcopy(self.rows)
|
||||
from harness.tests._support import spec as result
|
||||
spec_result = result.result('blocked_spec')
|
||||
spec_result['target'] = {'owner': 'acme', 'repository': 'demo', 'issue': 7, 'pull': 9, 'head': self.inp['head']}
|
||||
spec_result['contract_sha256'] = self.inp['contract_sha256']
|
||||
spec_result['criteria'][0]['result'] = 'indeterminate'
|
||||
spec_result['scope']['spec_conflicts'] = ['src/a.py']
|
||||
rows[0].update(state='COMMENT', body=result.mod.render_review(spec_result, {'src/a.py', 'tests/test_a.py'}, declaration={'mode': 'none'}))
|
||||
rows[1] = request
|
||||
self.set_rows(rows)
|
||||
receipt = self.snapshot()
|
||||
self.assertEqual(receipt['status'], 'blocked')
|
||||
self.assertIn('blocked_spec', receipt['reason_codes'])
|
||||
@@ -0,0 +1,205 @@
|
||||
import importlib.util
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from harness.tests._support.paths import ROOT
|
||||
|
||||
SCRIPT = ROOT / 'harness/agents/impl/skills/gitea-issue-rework/scripts/rework_input_gate.py'
|
||||
|
||||
class OrderedDecisionDiagnosticsTests(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
import copy, hashlib
|
||||
from harness.tests._support.rework import ReworkProviderFixture
|
||||
from harness.tests._support import spec as spec_tests
|
||||
self.rb = ReworkProviderFixture().setup()
|
||||
self.addCleanup(self.rb.cleanup)
|
||||
self.rb.repo['default_branch'] = 'main'
|
||||
self.rb.source['body'] = '<!-- gitea-agent:discovery-draft/v2 -->\nSynthetic approved source contract.\n'
|
||||
self.inp = {'schema': 'gitea-agent.rework-input/v1', 'target': {'owner': 'acme', 'repository': 'demo', 'issue': 7, 'pr': 9}, 'head': 'b' * 40, 'contract_sha256': hashlib.sha256(self.rb.source['body'].encode()).hexdigest()}
|
||||
self.rows = []
|
||||
for role, rid, uid in [('gitea-spec-agent', 301, 33), ('gitea-review-agent', 302, 34)]:
|
||||
row = copy.deepcopy(self.rb.reviews[0])
|
||||
row.update(id=rid, commit_id=self.inp['head'], state='APPROVED', user={'id': uid, 'login': role}, stale=False, dismissed=False, html_url=self.rb.pr['html_url'] + '#pullreview-' + str(rid))
|
||||
if role == 'gitea-spec-agent':
|
||||
result = spec_tests.result()
|
||||
result['target'] = dict(self.inp['target'])
|
||||
result['target']['pull'] = result['target'].pop('pr')
|
||||
result['target']['head'] = self.inp['head']
|
||||
result['contract_sha256'] = self.inp['contract_sha256']
|
||||
row['body'] = spec_tests.mod.render_review(result, {'src/a.py', 'tests/test_a.py'}, declaration={'mode': 'none'})
|
||||
else:
|
||||
row['body'] = self.code_body('approve')
|
||||
self.rows.append(row)
|
||||
self.set_rows(self.rows)
|
||||
original = self.rb.request
|
||||
|
||||
def request(path):
|
||||
from urllib.parse import urlsplit
|
||||
import re
|
||||
if re.fullmatch('/repos/acme/demo/pulls/9/reviews/[1-9][0-9]*/comments', urlsplit(path).path):
|
||||
self.rb.calls.append(path)
|
||||
return ([], {'X-Total-Count': '0'}, 200)
|
||||
return original(path)
|
||||
self.rb.request = request
|
||||
|
||||
def code_body(self, verdict):
|
||||
return '<!-- gitea-agent:code-review/v1 owner=acme repository=demo issue=7 pr=9 head=' + self.inp['head'] + ' contract-sha256=' + self.inp['contract_sha256'] + ' -->\n- Verdict: `' + verdict + '`\nBounded native code-review fixture.\n'
|
||||
|
||||
def set_rows(self, rows):
|
||||
import copy
|
||||
self.rb.reviews = copy.deepcopy(rows)
|
||||
self.rb.pr['comments'] = self.rb.underlying['comments'] = self.rb.pr['review_comments'] = len(rows)
|
||||
|
||||
def snapshot(self):
|
||||
return self.rb.adapter().input_snapshot(self.inp)
|
||||
|
||||
def spec_body(self, verdict, head=None):
|
||||
from harness.tests._support import spec as spec_tests
|
||||
result = spec_tests.result(verdict)
|
||||
result['target'] = {'owner': 'acme', 'repository': 'demo', 'issue': 7, 'pull': 9, 'head': head or self.inp['head']}
|
||||
result['contract_sha256'] = self.inp['contract_sha256']
|
||||
if verdict == 'request_changes':
|
||||
result['criteria'][0].update(result='unsatisfied', reason='Bounded regression finding.')
|
||||
result['conclusion'] = 'Bounded correction required.'
|
||||
return spec_tests.mod.render_review(result, {'src/a.py', 'tests/test_a.py'}, declaration={'mode': 'none'})
|
||||
|
||||
def test_pr_comment_counter_mismatch_preserves_no_op_and_diagnostics(self):
|
||||
self.assertEqual(self.snapshot()['status'], 'no_op')
|
||||
self.rb.pr['comments'] += 4
|
||||
receipt = self.snapshot()
|
||||
self.assertEqual(receipt['status'], 'no_op')
|
||||
diagnostics = receipt['counter_diagnostics']
|
||||
self.assertEqual(set(diagnostics), {'inline', 'pr', 'pr_issue'})
|
||||
self.assertEqual(diagnostics['pr']['status'], 'mismatch')
|
||||
|
||||
def test_review_counter_mismatch_preserves_rework(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.rb.pr['review_comments'] += 3
|
||||
receipt = self.snapshot()
|
||||
self.assertEqual(receipt['status'], 'rework')
|
||||
self.assertEqual(receipt['counter_diagnostics']['inline']['status'], 'mismatch')
|
||||
self.assertEqual(receipt['decision']['rework_review_ids'], [302])
|
||||
|
||||
def test_malformed_pr_counters_are_unverified_diagnostics(self):
|
||||
self.rb.pr['comments'] = 'not-a-count'
|
||||
receipt = self.snapshot()
|
||||
self.assertEqual(receipt['status'], 'no_op')
|
||||
self.assertEqual(receipt['counter_diagnostics']['pr']['status'], 'unverified')
|
||||
self.assertIn('field', receipt['counter_diagnostics']['pr'])
|
||||
|
||||
def test_unavailable_inline_endpoint_is_rejected_even_with_pr_count_fallback(self):
|
||||
from urllib.parse import urlsplit
|
||||
original = self.rb.request
|
||||
|
||||
def unavailable(path):
|
||||
if '/pulls/9/reviews/' in urlsplit(path).path and urlsplit(path).path.endswith('/comments'):
|
||||
self.rb.calls.append(path)
|
||||
return ({'message': 'not available'}, {}, 404)
|
||||
return original(path)
|
||||
self.rb.request = unavailable
|
||||
self.rb.contract['inline']['mode'] = 'per_review_or_pr_count'
|
||||
with self.assertRaisesRegex(ValueError, 'inline collection unavailable'):
|
||||
self.snapshot()
|
||||
|
||||
def test_inline_endpoint_405_is_rejected_even_with_pr_count_fallback(self):
|
||||
from urllib.parse import urlsplit
|
||||
original = self.rb.request
|
||||
|
||||
def unavailable(path):
|
||||
if '/pulls/9/reviews/' in urlsplit(path).path and urlsplit(path).path.endswith('/comments'):
|
||||
self.rb.calls.append(path)
|
||||
return ({'message': 'method not allowed'}, {}, 405)
|
||||
return original(path)
|
||||
self.rb.request = unavailable
|
||||
self.rb.contract['inline']['mode'] = 'per_review_or_pr_count'
|
||||
with self.assertRaisesRegex(ValueError, 'inline collection unavailable'):
|
||||
self.snapshot()
|
||||
|
||||
def test_publication_snapshot_remains_strict_on_pr_counter_mismatch(self):
|
||||
from harness.tests._support.rework import ReworkProviderFixture
|
||||
publication = ReworkProviderFixture().setup()
|
||||
self.addCleanup(publication.cleanup)
|
||||
publication.adapter().snapshot(publication.expected)
|
||||
publication.pr['comments'] += 1
|
||||
with self.assertRaisesRegex(ValueError, 'provider aggregate count mismatch'):
|
||||
publication.adapter().snapshot(publication.expected)
|
||||
|
||||
def test_complete_lists_with_zero_pr_issue_counter_preserve_no_op(self):
|
||||
self.rb.pr['comments'] = self.rb.underlying['comments'] = 0
|
||||
receipt = self.snapshot()
|
||||
self.assertEqual(receipt['status'], 'no_op')
|
||||
for scope in ('pr', 'pr_issue'):
|
||||
diagnostic = receipt['counter_diagnostics'][scope]
|
||||
self.assertEqual(diagnostic, {'field': 'comments', 'components': ['top_level', 'reviews', 'inline'], 'observed': 0, 'expected': 2, 'status': 'mismatch'})
|
||||
self.assertEqual(receipt['raw_input_snapshot'][scope]['comments'], 0)
|
||||
|
||||
def test_missing_and_invalid_counters_do_not_replace_record_validation(self):
|
||||
import copy
|
||||
saved_pr, saved_issue = (copy.deepcopy(self.rb.pr), copy.deepcopy(self.rb.underlying))
|
||||
for scope, field in (('inline', 'review_comments'), ('pr', 'comments'), ('pr_issue', 'comments')):
|
||||
for value in (None, True, -1, '0', 0.5, 'missing'):
|
||||
with self.subTest(scope=scope, value=value):
|
||||
self.rb.pr, self.rb.underlying = (copy.deepcopy(saved_pr), copy.deepcopy(saved_issue))
|
||||
record = self.rb.underlying if scope == 'pr_issue' else self.rb.pr
|
||||
if value == 'missing':
|
||||
record.pop(field)
|
||||
else:
|
||||
record[field] = value
|
||||
receipt = self.snapshot()
|
||||
self.assertEqual(receipt['status'], 'no_op')
|
||||
self.assertEqual(receipt['counter_diagnostics'][scope]['status'], 'unverified')
|
||||
raw_record = receipt['raw_input_snapshot']['pr_issue' if scope == 'pr_issue' else 'pr']
|
||||
if value == 'missing':
|
||||
self.assertNotIn(field, raw_record)
|
||||
else:
|
||||
self.assertEqual(raw_record[field], value)
|
||||
|
||||
def test_counter_mismatch_does_not_unblock_lone_approval(self):
|
||||
self.set_rows(self.rows[:1])
|
||||
self.rb.pr['comments'] = self.rb.underlying['comments'] = 0
|
||||
receipt = self.snapshot()
|
||||
self.assertEqual(receipt['status'], 'blocked')
|
||||
self.assertEqual(receipt['counter_diagnostics']['pr']['status'], 'mismatch')
|
||||
self.assertEqual(receipt['decision']['rework_review_ids'], [])
|
||||
|
||||
def test_lone_request_changes_keeps_rework_despite_counter_mismatch(self):
|
||||
import copy
|
||||
request = copy.deepcopy(self.rows[1])
|
||||
request.update(state='REQUEST_CHANGES', body=self.code_body('request_changes'))
|
||||
self.set_rows([request])
|
||||
self.rb.pr['comments'] += 2
|
||||
receipt = self.snapshot()
|
||||
self.assertEqual(receipt['status'], 'rework', receipt['reason_codes'])
|
||||
self.assertEqual(receipt['decision']['rework_review_ids'], [302])
|
||||
self.assertEqual(receipt['counter_diagnostics']['pr']['status'], 'mismatch')
|
||||
|
||||
def test_counter_mismatch_does_not_hide_per_review_inline_count_mismatch(self):
|
||||
self.rb.reviews[0]['comments_count'] = 1
|
||||
self.rb.pr['comments'] = self.rb.underlying['comments'] = 0
|
||||
with self.assertRaisesRegex(ValueError, 'per-review inline count mismatch'):
|
||||
self.snapshot()
|
||||
|
||||
def test_counter_mismatch_does_not_hide_duplicate_review_records(self):
|
||||
import copy
|
||||
self.rb.reviews.append(copy.deepcopy(self.rb.reviews[0]))
|
||||
self.rb.pr['comments'] = self.rb.underlying['comments'] = 0
|
||||
with self.assertRaisesRegex(ValueError, 'duplicate'):
|
||||
self.snapshot()
|
||||
|
||||
def test_counter_mismatch_does_not_hide_incomplete_review_pagination(self):
|
||||
original = self.rb.request
|
||||
|
||||
def truncated(path):
|
||||
if '/pulls/9/reviews?' in path:
|
||||
payload, headers, status = original(path)
|
||||
return (payload, dict(headers, **{'X-Total-Count': '3'}), status)
|
||||
return original(path)
|
||||
self.rb.request = truncated
|
||||
self.rb.pr['comments'] = self.rb.underlying['comments'] = 0
|
||||
with self.assertRaisesRegex(ValueError, 'truncated collection'):
|
||||
self.snapshot()
|
||||
@@ -0,0 +1,36 @@
|
||||
import importlib.util
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from harness.tests._support.paths import ROOT
|
||||
|
||||
SCRIPT = ROOT / 'harness/agents/impl/skills/gitea-issue-rework/scripts/rework_input_gate.py'
|
||||
|
||||
class InputGateTests(unittest.TestCase):
|
||||
|
||||
def load(self):
|
||||
spec = importlib.util.spec_from_file_location('rework_input_gate', SCRIPT)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
def test_valid_input_schema_is_accepted(self):
|
||||
gate = self.load()
|
||||
value = {'schema': 'gitea-agent.rework-input/v1', 'target': {'owner': 'acme', 'repository': 'demo', 'issue': 7, 'pr': 9}, 'head': 'a' * 40, 'contract_sha256': 'b' * 64}
|
||||
self.assertEqual(gate.validate_input(value), value)
|
||||
|
||||
def test_initial_output_is_immutable_and_rejected_before_credential_or_get(self):
|
||||
cli_path = SCRIPT.with_name('rework_readback_cli.py')
|
||||
spec = importlib.util.spec_from_file_location('rework_readback_cli', cli_path)
|
||||
cli = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(cli)
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
output = Path(root) / 'decision.json'
|
||||
output.write_bytes(b'old\n')
|
||||
output.chmod(384)
|
||||
mode = output.stat().st_mode
|
||||
with self.assertRaisesRegex(ValueError, 'output already exists'):
|
||||
cli.reject_existing_output(str(output))
|
||||
self.assertEqual(output.read_bytes(), b'old\n')
|
||||
self.assertEqual(output.stat().st_mode, mode)
|
||||
@@ -0,0 +1,161 @@
|
||||
"""Regression tests for main-owned rework publication integration."""
|
||||
import copy
|
||||
import hashlib
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock
|
||||
|
||||
from harness.tests._support.paths import HARNESS as ROOT
|
||||
SCRIPT = ROOT / "agents/impl/skills/gitea-issue-rework/scripts"
|
||||
sys.path.insert(0, str(SCRIPT))
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
import rework_publication as pub
|
||||
from harness.lib import impl_operation as ops
|
||||
|
||||
HEAD = "a" * 40
|
||||
|
||||
def expected():
|
||||
# Generate through the canonical renderer to avoid hand-authored bodies.
|
||||
import rework_comment as rc
|
||||
d = dict(owner="o", repository="r", issue=4, pr=5, review=6,
|
||||
reviewed_head="b"*40, final_head=HEAD, contract_sha256="c"*64,
|
||||
result="partial", findings=[dict(id="F1", status="resolved", classification="valid_fix",
|
||||
changes="changed safely", verification="checks pass", reason="verified")],
|
||||
local_checks="pass", runtime_browser="not run", commit_status="없음", actions_runs="없음")
|
||||
dd, vals, fs, extras, cs, ar = rc.canon(d)
|
||||
body = rc.render_body(dd, vals, fs, extras, cs, ar)
|
||||
rh = hashlib.sha256(body.encode()).hexdigest()
|
||||
corr = hashlib.sha256("\0".join(["o", "r", "4", "5", "6", "b"*40, HEAD, "c"*64]).encode()).hexdigest()
|
||||
marker = f"<!-- gitea-agent:implementation-rework/v1 owner=o repository=r issue=4 pr=5 review=6 reviewed-head={'b'*40} head={HEAD} contract-sha256={'c'*64} result-sha256={rh} correlation={corr} -->\n"
|
||||
return dict(body=body+marker, marker=marker, marker_free_body=body, result_sha256=rh, correlation=corr,
|
||||
target=dict(owner="o", repository="r", issue=4, pr=5), review=6,
|
||||
reviewed_head="b"*40, head=HEAD, contract_sha256="c"*64)
|
||||
|
||||
def setup_case(op="comment.create", payload=None, approval_flags=None):
|
||||
if payload is None:
|
||||
e=expected(); payload={"expected":e,"target":e["target"]}
|
||||
intent=ops.Intent("v1", op, "o/r", 5, HEAD, payload)
|
||||
approval=ops.Approval("v1", intent.digest, "approved", **(approval_flags or {}))
|
||||
declaration={"mode":"none"}
|
||||
calls=[]
|
||||
adapter=ops.Adapter(preflight=lambda i: {"fresh":True,"scope":"exact"}, capability=True,
|
||||
mutate=lambda i: calls.append(copy.deepcopy(i)) or {"acknowledgement":"ok"},
|
||||
readback=lambda i: {"scope":"exact","head":HEAD}, verify=lambda i,p,o: True)
|
||||
return intent,approval,declaration,calls,adapter
|
||||
|
||||
class PublicationTests(unittest.TestCase):
|
||||
|
||||
def test_invalid_body_target_head_approval_do_not_write(self):
|
||||
cases = []
|
||||
cases.append(setup_case('pr.patch', {'body': 'body', 'closing': '#4'}))
|
||||
cases.append(setup_case('comment.create', {'expected': expected(), 'target': {'owner': 'evil', 'repository': 'r', 'issue': 4, 'pr': 5}}))
|
||||
p = {'head': 'f' * 40}
|
||||
cases.append(setup_case('push', p))
|
||||
for intent, approval, decl, calls, adapter in cases:
|
||||
with self.subTest(intent=intent.operation), self.assertRaises(Exception):
|
||||
pub.execute_rework_operation(adapter, intent=intent, approval=approval, declaration=decl, coordinator=ops.Coordinator(), on_attempt=lambda x: None)
|
||||
self.assertEqual(calls, [])
|
||||
|
||||
def test_single_mutation_and_fresh_adoption_at_boundary(self):
|
||||
intent, approval, decl, calls, adapter = setup_case()
|
||||
old = pub.require_adoption
|
||||
try:
|
||||
adopted = Mock(return_value=None)
|
||||
pub.require_adoption = adopted
|
||||
attempts = []
|
||||
result = pub.execute_rework_operation(adapter, intent=intent, approval=approval, declaration=decl, coordinator=ops.Coordinator(), on_attempt=attempts.append)
|
||||
self.assertEqual(result.status, 'verified')
|
||||
self.assertEqual(len(calls), 1)
|
||||
self.assertEqual(len(attempts), 1)
|
||||
adopted.assert_called_once()
|
||||
finally:
|
||||
pub.require_adoption = old
|
||||
|
||||
def test_adoption_and_preflight_failure_have_zero_mutations(self):
|
||||
intent, approval, decl, calls, adapter = setup_case()
|
||||
old = pub.require_adoption
|
||||
try:
|
||||
pub.require_adoption = Mock(side_effect=ValueError('stale'))
|
||||
r = pub.execute_rework_operation(adapter, intent=intent, approval=approval, declaration=decl, coordinator=ops.Coordinator(), on_attempt=lambda x: None)
|
||||
self.assertEqual(r.status, 'partial')
|
||||
self.assertEqual(calls, [])
|
||||
adapter.preflight = lambda i: {'fresh': False}
|
||||
r = pub.execute_rework_operation(adapter, intent=intent, approval=approval, declaration=decl, coordinator=ops.Coordinator(), on_attempt=lambda x: None)
|
||||
self.assertEqual(r.status, 'partial')
|
||||
self.assertEqual(calls, [])
|
||||
finally:
|
||||
pub.require_adoption = old
|
||||
|
||||
def test_uncertain_ack_readback_once_literal_true_required(self):
|
||||
intent, approval, decl, calls, adapter = setup_case()
|
||||
old = pub.require_adoption
|
||||
try:
|
||||
pub.require_adoption = Mock(return_value=None)
|
||||
mutations = []
|
||||
reads = []
|
||||
adapter.mutate = lambda i: mutations.append(i) or (_ for _ in ()).throw(ops.UncertainAcknowledgement())
|
||||
adapter.readback = lambda i: reads.append(i) or {'head': HEAD}
|
||||
adapter.verify = lambda i, p, o: 1
|
||||
result = pub.execute_rework_operation(adapter, intent=intent, approval=approval, declaration=decl, coordinator=ops.Coordinator(), on_attempt=lambda x: None)
|
||||
self.assertEqual(result.status, 'partial')
|
||||
self.assertEqual((len(mutations), len(reads)), (1, 1))
|
||||
finally:
|
||||
pub.require_adoption = old
|
||||
|
||||
def test_reuse_collects_fresh_state_without_any_mutation(self):
|
||||
intent, approval, declaration, calls, adapter = setup_case()
|
||||
reads = []
|
||||
adapter.preflight = lambda value: reads.append('preflight') or {'fresh': True}
|
||||
adapter.readback = lambda value: reads.append('readback') or {'exact': True}
|
||||
adapter.verify_reuse = lambda value, before, after: after.get('exact') is True
|
||||
result = pub.verify_rework_reuse(adapter, intent=intent, approval=approval, declaration=declaration)
|
||||
self.assertEqual(result['action'], 'reuse')
|
||||
self.assertEqual(reads, ['preflight', 'readback'])
|
||||
self.assertEqual(calls, [])
|
||||
|
||||
def test_reuse_stale_preflight_blocks_even_when_callback_would_accept(self):
|
||||
intent, approval, declaration, calls, adapter = setup_case()
|
||||
reads = []
|
||||
adapter.preflight = lambda value: {'fresh': False}
|
||||
adapter.readback = lambda value: reads.append(True)
|
||||
adapter.verify_reuse = lambda *args: True
|
||||
with self.assertRaises(ValueError):
|
||||
pub.verify_rework_reuse(adapter, intent=intent, approval=approval, declaration=declaration)
|
||||
self.assertEqual(reads, [])
|
||||
self.assertEqual(calls, [])
|
||||
|
||||
def test_canonical_comment_cannot_publish_to_different_intent_scope(self):
|
||||
intent, approval, declaration, calls, adapter = setup_case()
|
||||
for target, target_id in [('evil/r', 5), ('o/r', 999)]:
|
||||
changed = ops.Intent('v1', intent.operation, target, target_id, HEAD, intent.payload)
|
||||
approved = ops.Approval('v1', changed.digest, 'approved')
|
||||
with self.subTest(target=target, target_id=target_id), self.assertRaises(ValueError):
|
||||
pub.execute_rework_operation(adapter, intent=changed, approval=approved, declaration=declaration, coordinator=ops.Coordinator(), on_attempt=lambda record: None)
|
||||
self.assertEqual(calls, [])
|
||||
|
||||
def test_unimplemented_client_cas_correction_blocks_before_write(self):
|
||||
import rework_comment as rc
|
||||
new = expected()
|
||||
old = copy.deepcopy(new)
|
||||
body = new['marker_free_body']
|
||||
for label in ('Source Issue', 'Pull Request', 'Source Review'):
|
||||
body = body.replace('- ' + label + ': ', '- ' + label + ': #')
|
||||
digest = hashlib.sha256(body.encode()).hexdigest()
|
||||
marker = new['marker'].replace(new['result_sha256'], digest)
|
||||
old.update(body=body + marker, marker=marker, marker_free_body=body, result_sha256=digest)
|
||||
payload = {'old_expected': old, 'new_expected': new, 'comment_id': 7, 'actor_id': 42}
|
||||
intent = ops.Intent('v1', 'correction', 'o/r', 7, HEAD, payload)
|
||||
approval = ops.Approval('v1', intent.digest, 'approved', correction=True)
|
||||
calls = []
|
||||
adapter = ops.Adapter(lambda value: {'fresh': True}, True, lambda value: calls.append('write'), lambda value: {'exact': True}, lambda *args: True)
|
||||
with self.assertRaises(ValueError):
|
||||
pub.execute_rework_operation(adapter, intent=intent, approval=approval, declaration={'mode': 'none'}, coordinator=ops.Coordinator(), on_attempt=lambda record: None)
|
||||
self.assertEqual(calls, [])
|
||||
|
||||
def test_generic_comment_patch_rejected_and_correction_requires_explicit_approval(self):
|
||||
intent, approval, decl, calls, adapter = setup_case('comment.patch', {'body': 'arbitrary'})
|
||||
with self.assertRaises(ValueError):
|
||||
pub.execute_rework_operation(adapter, intent=intent, approval=approval, declaration=decl, coordinator=ops.Coordinator(), on_attempt=lambda x: None)
|
||||
self.assertEqual(calls, [])
|
||||
@@ -0,0 +1,157 @@
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import unittest
|
||||
from argparse import Namespace
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
from harness.tests._support.contracts import read_contract
|
||||
from harness.tests._support.rework import DIRECTORY, ORIGIN, T0, T1, ReworkProviderFixture
|
||||
|
||||
class ReworkReadbackBridgeTests(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self._fixture = ReworkProviderFixture().setup()
|
||||
self.addCleanup(self._fixture.cleanup)
|
||||
|
||||
@property
|
||||
def temp(self):
|
||||
return self._fixture._temp
|
||||
|
||||
def adapter(self):
|
||||
module = self._fixture.load('rework_readback')
|
||||
return module.ReworkReadbackAdapter(self._fixture._request_override or self._fixture.request, origin=ORIGIN + '/api/v1', credential_source='$HERMES_HOME/.env:GITEA_TOKEN', actor_id=42, provider_contract=self._fixture.contract)
|
||||
|
||||
def adapter_for(self, request):
|
||||
module = self._fixture.load('rework_readback')
|
||||
return module.ReworkReadbackAdapter(request, origin=ORIGIN + '/api/v1', credential_source='$HERMES_HOME/.env:GITEA_TOKEN', actor_id=42, provider_contract=self._fixture.contract)
|
||||
|
||||
def __getattr__(self, name):
|
||||
fixture = self.__dict__.get('_fixture')
|
||||
if fixture is not None and name == 'request' and (fixture._request_override is not None):
|
||||
return fixture._request_override
|
||||
if fixture is not None and hasattr(fixture, name):
|
||||
return getattr(fixture, name)
|
||||
raise AttributeError(name)
|
||||
|
||||
def __setattr__(self, name, value):
|
||||
fixture = self.__dict__.get('_fixture')
|
||||
if fixture is not None and name == 'request':
|
||||
fixture._request_override = value if value is not fixture.request else None
|
||||
return
|
||||
if fixture is not None and name != '_fixture' and hasattr(fixture, name):
|
||||
setattr(fixture, name, value)
|
||||
return
|
||||
super().__setattr__(name, value)
|
||||
|
||||
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=448)
|
||||
secret = 'synthetic-fixture-' + 'q' * 40
|
||||
(profile / '.env').write_text('GITEA_TOKEN=' + secret + '\n')
|
||||
(profile / '.env').chmod(384)
|
||||
(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_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 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)
|
||||
self.assertIn('repository-cache.md#commit-message-reference-safety', skill)
|
||||
@@ -0,0 +1,187 @@
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import unittest
|
||||
from argparse import Namespace
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
from harness.tests._support.contracts import read_contract
|
||||
from harness.tests._support.rework import DIRECTORY, ORIGIN, T0, T1, ReworkProviderFixture
|
||||
|
||||
class ReworkReadbackCacheTests(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self._fixture = ReworkProviderFixture().setup()
|
||||
self.addCleanup(self._fixture.cleanup)
|
||||
|
||||
@property
|
||||
def temp(self):
|
||||
return self._fixture._temp
|
||||
|
||||
def adapter(self):
|
||||
module = self._fixture.load('rework_readback')
|
||||
return module.ReworkReadbackAdapter(self._fixture._request_override or self._fixture.request, origin=ORIGIN + '/api/v1', credential_source='$HERMES_HOME/.env:GITEA_TOKEN', actor_id=42, provider_contract=self._fixture.contract)
|
||||
|
||||
def adapter_for(self, request):
|
||||
module = self._fixture.load('rework_readback')
|
||||
return module.ReworkReadbackAdapter(request, origin=ORIGIN + '/api/v1', credential_source='$HERMES_HOME/.env:GITEA_TOKEN', actor_id=42, provider_contract=self._fixture.contract)
|
||||
|
||||
def __getattr__(self, name):
|
||||
fixture = self.__dict__.get('_fixture')
|
||||
if fixture is not None and name == 'request' and (fixture._request_override is not None):
|
||||
return fixture._request_override
|
||||
if fixture is not None and hasattr(fixture, name):
|
||||
return getattr(fixture, name)
|
||||
raise AttributeError(name)
|
||||
|
||||
def __setattr__(self, name, value):
|
||||
fixture = self.__dict__.get('_fixture')
|
||||
if fixture is not None and name == 'request':
|
||||
fixture._request_override = value if value is not fixture.request else None
|
||||
return
|
||||
if fixture is not None and name != '_fixture' and hasattr(fixture, name):
|
||||
setattr(fixture, name, value)
|
||||
return
|
||||
super().__setattr__(name, value)
|
||||
|
||||
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')
|
||||
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('```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)
|
||||
@@ -0,0 +1,360 @@
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import unittest
|
||||
from argparse import Namespace
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
from harness.tests._support.contracts import read_contract
|
||||
from harness.tests._support.rework import DIRECTORY, ORIGIN, T0, T1, ReworkProviderFixture
|
||||
|
||||
class ReworkReadbackProviderTests(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self._fixture = ReworkProviderFixture().setup()
|
||||
self.addCleanup(self._fixture.cleanup)
|
||||
|
||||
@property
|
||||
def temp(self):
|
||||
return self._fixture._temp
|
||||
|
||||
def adapter(self):
|
||||
module = self._fixture.load('rework_readback')
|
||||
return module.ReworkReadbackAdapter(self._fixture._request_override or self._fixture.request, origin=ORIGIN + '/api/v1', credential_source='$HERMES_HOME/.env:GITEA_TOKEN', actor_id=42, provider_contract=self._fixture.contract)
|
||||
|
||||
def adapter_for(self, request):
|
||||
module = self._fixture.load('rework_readback')
|
||||
return module.ReworkReadbackAdapter(request, origin=ORIGIN + '/api/v1', credential_source='$HERMES_HOME/.env:GITEA_TOKEN', actor_id=42, provider_contract=self._fixture.contract)
|
||||
|
||||
def __getattr__(self, name):
|
||||
fixture = self.__dict__.get('_fixture')
|
||||
if fixture is not None and name == 'request' and (fixture._request_override is not None):
|
||||
return fixture._request_override
|
||||
if fixture is not None and hasattr(fixture, name):
|
||||
return getattr(fixture, name)
|
||||
raise AttributeError(name)
|
||||
|
||||
def __setattr__(self, name, value):
|
||||
fixture = self.__dict__.get('_fixture')
|
||||
if fixture is not None and name == 'request':
|
||||
fixture._request_override = value if value is not fixture.request else None
|
||||
return
|
||||
if fixture is not None and name != '_fixture' and hasattr(fixture, name):
|
||||
setattr(fixture, name, value)
|
||||
return
|
||||
super().__setattr__(name, value)
|
||||
|
||||
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._fixture.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, base_request=original, drift_endpoint=endpoint, call_counts=counts):
|
||||
value, headers, status = base_request(path)
|
||||
key = urlsplit(path).path
|
||||
call_counts[key] = call_counts.get(key, 0) + 1
|
||||
if key == drift_endpoint and call_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_for(changing).snapshot(self.expected)
|
||||
finally:
|
||||
self._fixture._request_override = None
|
||||
|
||||
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_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)
|
||||
@@ -0,0 +1,221 @@
|
||||
import importlib.util
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from harness.tests._support.paths import ROOT
|
||||
|
||||
SCRIPT = ROOT / 'harness/agents/impl/skills/gitea-issue-rework/scripts/rework_comment.py'
|
||||
|
||||
class ReworkReadbackTransportTests(unittest.TestCase):
|
||||
|
||||
def module(self):
|
||||
path = SCRIPT.with_name('rework_readback_cli.py')
|
||||
self.assertTrue(path.is_file(), 'canonical live readback bridge is missing')
|
||||
spec = importlib.util.spec_from_file_location('transport_under_test', 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 test_role_token_requires_private_profile_file(self):
|
||||
module = self.module()
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
home = Path(directory)
|
||||
with self.assertRaises(ValueError):
|
||||
module.load_role_token(home)
|
||||
source = home / '.env'
|
||||
source.write_text('GITEA_TOKEN=' + 's' * 48 + '\n')
|
||||
source.chmod(420)
|
||||
with self.assertRaises(ValueError):
|
||||
module.load_role_token(home)
|
||||
source.chmod(384)
|
||||
self.assertEqual(module.load_role_token(home), 's' * 48)
|
||||
source.unlink()
|
||||
external = home / 'external'
|
||||
external.write_text('GITEA_TOKEN=' + 's' * 48)
|
||||
external.chmod(384)
|
||||
source.symlink_to(external)
|
||||
with self.assertRaises(ValueError):
|
||||
module.load_role_token(home)
|
||||
|
||||
def test_http_transport_get_only_redirects_and_secret_echo_are_rejected(self):
|
||||
module = self.module()
|
||||
self.assertTrue(callable(getattr(module, 'RestTransport', None)), 'read-only HTTP transport missing')
|
||||
import http.server
|
||||
import threading
|
||||
import urllib.request
|
||||
requests = []
|
||||
secret = 'z' * 48
|
||||
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
|
||||
def log_message(self, format, *args):
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
requests.append((self.path, self.headers.get('Authorization') == 'token ' + secret))
|
||||
if self.path == '/api/v1/version':
|
||||
self.send_response(302)
|
||||
self.send_header('Location', 'https://outside.invalid/user')
|
||||
self.end_headers()
|
||||
return
|
||||
body = b'{"credential":"' + secret.encode() + b'"}' if self.path.endswith('/secret') else b'{"id":42,"login":"gitea-impl-agent"}'
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
if self.path.endswith('/duplicate'):
|
||||
self.send_header('X-Total-Count', '1')
|
||||
self.send_header('X-Total-Count', '2')
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
|
||||
def redirect_request(self, *args, **kwargs):
|
||||
return None
|
||||
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(NoRedirect())
|
||||
|
||||
class Proxy:
|
||||
|
||||
def open(self, request, timeout):
|
||||
self_request = 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(self_request, timeout=timeout)
|
||||
original = request.full_url
|
||||
|
||||
class Response:
|
||||
status = response.status
|
||||
headers = response.headers
|
||||
|
||||
def geturl(self):
|
||||
return original
|
||||
|
||||
def read1(self, n):
|
||||
return response.read1(n)
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
response.close()
|
||||
return Response()
|
||||
try:
|
||||
client = module.RestTransport(secret, opener=Proxy())
|
||||
data, _, status = client('/user')
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(data['id'], 42)
|
||||
self.assertEqual(client('/version')[2], 302)
|
||||
with self.assertRaises(ValueError) as raised:
|
||||
client('/repos/acme/widget/secret')
|
||||
self.assertNotIn(secret, str(raised.exception))
|
||||
with self.assertRaises(ValueError):
|
||||
client('/repos/acme/widget/duplicate')
|
||||
self.assertTrue(all((authenticated for _, authenticated in requests)))
|
||||
self.assertEqual(len(requests), 4)
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=3)
|
||||
|
||||
def test_output_admission_preserves_receipt_on_failure_and_rejects_links(self):
|
||||
module = self.module()
|
||||
self.assertTrue(callable(getattr(module, 'OutputFile', None)), 'pinned output writer missing')
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
receipt = Path(directory) / 'receipt.json'
|
||||
receipt.write_text('original')
|
||||
receipt.chmod(384)
|
||||
with module.OutputFile(receipt):
|
||||
pass
|
||||
self.assertEqual(receipt.read_text(), 'original')
|
||||
with module.OutputFile(receipt) as output:
|
||||
output.write({'status': 'verified'})
|
||||
self.assertIn('verified', receipt.read_text())
|
||||
self.assertEqual(receipt.stat().st_mode & 511, 384)
|
||||
foreign = Path(directory) / 'foreign'
|
||||
foreign.write_text('preserve')
|
||||
receipt.unlink()
|
||||
receipt.symlink_to(foreign)
|
||||
with self.assertRaises(ValueError):
|
||||
module.OutputFile(receipt)
|
||||
self.assertEqual(foreign.read_text(), 'preserve')
|
||||
|
||||
def test_http_operation_budget_stops_before_next_get(self):
|
||||
module = self.module()
|
||||
from unittest.mock import patch
|
||||
clock = [0]
|
||||
calls = []
|
||||
|
||||
class Opener:
|
||||
|
||||
def open(self, request, timeout):
|
||||
calls.append(request.full_url)
|
||||
|
||||
class Response:
|
||||
status = 200
|
||||
headers = {'Content-Type': 'application/json'}
|
||||
emitted = False
|
||||
|
||||
def geturl(self):
|
||||
return request.full_url
|
||||
|
||||
def read1(self, n):
|
||||
if self.emitted:
|
||||
return b''
|
||||
self.emitted = True
|
||||
return b'{"id":42}'
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
pass
|
||||
return Response()
|
||||
with patch.object(module.time, 'monotonic', side_effect=lambda: clock[0]):
|
||||
client = module.RestTransport('b' * 48, opener=Opener())
|
||||
self.assertEqual(client('/user')[2], 200)
|
||||
clock[0] = 121
|
||||
with self.assertRaises(ValueError):
|
||||
client('/version')
|
||||
self.assertEqual(len(calls), 1)
|
||||
|
||||
def test_live_bridge_rejects_unbound_provider_before_transport(self):
|
||||
module = self.module()
|
||||
self.assertTrue(callable(getattr(module, 'run_live', None)), 'live verifier bridge missing')
|
||||
import hashlib
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
expected = root / 'expected.json'
|
||||
expected.write_text('{}')
|
||||
contract = root / 'provider.json'
|
||||
contract.write_text('{"schema":"test-contract"}')
|
||||
trusted = hashlib.sha256(contract.read_bytes()).hexdigest()
|
||||
self.assertEqual(module.read_bound_json(contract, trusted)['schema'], 'test-contract')
|
||||
receipt = root / 'receipt.json'
|
||||
receipt.write_text('preserve')
|
||||
receipt.chmod(384)
|
||||
args = SimpleNamespace(cmd='snapshot', expected=str(expected), provider_contract=str(contract), provider_contract_sha256='0' * 64, actor_id=42, output=str(receipt))
|
||||
with patch.object(module, 'RestTransport') as transport:
|
||||
with patch.object(module, 'read_bound_json', wraps=module.read_bound_json) as reader:
|
||||
with self.assertRaises(ValueError):
|
||||
module.run_live(args, lambda *a, **k: None)
|
||||
self.assertGreaterEqual(reader.call_count, 2)
|
||||
transport.assert_not_called()
|
||||
self.assertEqual(receipt.read_text(), 'preserve')
|
||||
|
||||
def test_existing_command_exposes_readonly_snapshot(self):
|
||||
result = subprocess.run([sys.executable, '-B', str(SCRIPT), 'snapshot', '--help'], capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn('--provider-contract', result.stdout)
|
||||
self.assertIn('--actor-id', result.stdout)
|
||||
|
||||
def test_existing_command_exposes_live_verification(self):
|
||||
result = subprocess.run([sys.executable, '-B', str(SCRIPT), 'verify-live', '--help'], capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn('--baseline-sha256', result.stdout)
|
||||
Reference in New Issue
Block a user