Files
gitea-agent/harness/tests/spec/test_source_issue_reference_projection.py
T

323 lines
20 KiB
Python

import copy
import json
import subprocess
import sys
import tempfile
import unittest
from harness.tests.spec import test_implementation_spec_review_support as fixture
class SourceIssueReferenceProjectionTests(unittest.TestCase):
def contract(self):
return {'version': {'version': '1.27.3'},
'source_issue_cross_reference_projection': True}
def snapshots(self):
before, after, review, result, paths = fixture.publication_fixture()
url = before['pr']['html_url']
issue_url = url.rsplit('/pulls/', 1)[0] + '/issues/3'
before['issue']['html_url'] = after['issue']['html_url'] = issue_url
pr_issue = {'id': 70, 'number': 7, 'html_url': url,
'repository': {'full_name': 'acme/repo'}, 'user': {'id': 99},
'state': 'open', 'title': 'implementation', 'body': 'Closes #3',
'comments': 2, 'updated_at': before['pr']['updated_at'],
'pull_request': {'merged': False, 'html_url': url}}
before['pr_issue'] = pr_issue
after['pr_issue'] = copy.deepcopy(pr_issue)
# Native top-level reviews change review_comments, not ordinary comments.
after['pr_issue'].update(updated_at=review['submitted_at'])
event = after['timeline'][0]
event['html_url'] = url + '#issuecomment-101'
event['issue_url'] = ''
event['assets'] = []
ref_comment = {key: copy.deepcopy(event[key]) for key in
('id', 'html_url', 'pull_request_url', 'issue_url', 'user',
'body', 'assets', 'created_at', 'updated_at')}
reference = {'id': 102, 'type': 'comment_ref', 'user': copy.deepcopy(review['user']),
'body': '', 'html_url': issue_url + '#issuecomment-102',
'issue_url': issue_url, 'pull_request_url': '',
'created_at': '2026-10-03T01:24:01Z',
'updated_at': '2026-10-03T01:24:01Z',
'ref_issue': copy.deepcopy(after['pr_issue']),
'ref_comment': ref_comment, 'ref_action': 'none', 'review_id': 0}
before['issue_timeline'] = []
after['issue_timeline'] = [reference]
after['issue']['updated_at'] = reference['created_at']
return before, after, review, result, paths
def test_exact_reference_allows_only_source_issue_timestamp(self):
before, after, review, result, paths = self.snapshots()
original = copy.deepcopy((before, after))
try:
actual = fixture.verify_fixture(before, after, review, result, paths, self.contract())
except fixture.mod.ValidationError as exc:
self.fail('exact review-bound source reference rejected: ' + str(exc))
self.assertEqual(actual['source_issue_reference_ids'], [102])
self.assertEqual((before, after), original)
after['pr']['updated_at'] = after['issue']['updated_at']
with self.assertRaisesRegex(fixture.mod.ValidationError, 'timestamp projection mismatch'):
fixture.verify_fixture(before, after, review, result, paths, self.contract())
def test_pr_issue_comments_stay_unchanged_while_review_count_increments(self):
for count in (0, 2):
before, after, review, result, paths = self.snapshots()
before['pr_issue']['comments'] = after['pr_issue']['comments'] = count
after['issue_timeline'][-1]['ref_issue'] = copy.deepcopy(after['pr_issue'])
with self.subTest(count=count):
fixture.verify_fixture(before, after, review, result, paths, self.contract())
self.assertEqual(after['pr_issue']['comments'], before['pr_issue']['comments'])
self.assertEqual(after['pr']['review_comments'], before['pr']['review_comments'] + 1)
def test_pr_issue_comment_changes_and_invalid_counts_are_rejected(self):
baseline = self.snapshots()
fixture.verify_fixture(*baseline, self.contract())
# Reject both directions, including the old erroneous +1 projection.
for value in (1, 3, 4, -1, True, '2', None):
args = copy.deepcopy(baseline)
args[1]['pr_issue']['comments'] = value
args[1]['issue_timeline'][-1]['ref_issue'] = copy.deepcopy(args[1]['pr_issue'])
with self.subTest(after=value), self.assertRaisesRegex(
fixture.mod.ValidationError, 'PR Issue count projection mismatch'):
fixture.verify_fixture(*args, self.contract())
for value in (-1, True, '2', None):
args = copy.deepcopy(baseline)
args[0]['pr_issue']['comments'] = args[1]['pr_issue']['comments'] = value
args[1]['issue_timeline'][-1]['ref_issue'] = copy.deepcopy(args[1]['pr_issue'])
with self.subTest(both=value), self.assertRaisesRegex(
fixture.mod.ValidationError, 'PR Issue count projection mismatch'):
fixture.verify_fixture(*args, self.contract())
for side in (0, 1):
args = copy.deepcopy(baseline)
del args[side]['pr_issue']['comments']
with self.subTest(missing=side), self.assertRaises(fixture.mod.ValidationError):
fixture.verify_fixture(*args, self.contract())
def test_source_reference_does_not_relax_pr_review_comment_increment(self):
baseline = self.snapshots()
fixture.verify_fixture(*baseline, self.contract())
for value in (0, 2, -1, True, '1', None):
args = copy.deepcopy(baseline)
args[1]['pr']['review_comments'] = value
with self.subTest(after=value), self.assertRaisesRegex(
fixture.mod.ValidationError, 'review count projection mismatch'):
fixture.verify_fixture(*args, self.contract())
def test_wrong_reference_identity_payload_or_timestamp_is_rejected(self):
baseline = self.snapshots()
fixture.verify_fixture(*baseline, self.contract())
mutations = [('type', 'comment'), ('user.id', 43), ('user.login', 'changed'),
('ref_issue.number', 8), ('ref_issue.repository.full_name', 'other/repo'),
('ref_issue.id', 71), ('ref_comment.id', 100), ('ref_comment.id', True),
('ref_comment.user.id', 43), ('ref_comment.body', 'other review'),
('ref_comment.created_at', '2026-10-03T01:24:01Z'),
('ref_comment.updated_at', '2026-10-03T01:24:01Z'),
('ref_comment.html_url', 'https://other.test/acme/repo/pulls/7#issuecomment-101'),
('ref_comment.pull_request_url', 'https://other.test/acme/repo/pulls/7'),
('ref_comment.assets', [{'id': 9}]), ('ref_comment.issue_url', 'wrong'),
('issue_url', 'https://example.test/acme/repo/issues/4'),
('html_url', 'https://example.test/acme/repo/issues/3#issuecomment-103'),
('pull_request_url', 'https://example.test/acme/repo/pulls/7'),
('ref_action', 'close'), ('review_id', True), ('body', 'unrelated'),
('created_at', '2026-10-03T01:23:59Z'), ('updated_at', '2026-10-03T01:24:02Z')]
for field, value in mutations:
before, after, review, result, paths = copy.deepcopy(baseline)
node = after['issue_timeline'][-1]
parts = field.split('.')
for key in parts[:-1]:
node = node[key]
node[parts[-1]] = value
with self.subTest(field=field, value=value), self.assertRaises(fixture.mod.ValidationError):
fixture.verify_fixture(before, after, review, result, paths, self.contract())
for field in ('ref_issue', 'ref_comment', 'created_at', 'updated_at', 'user'):
before, after, review, result, paths = copy.deepcopy(baseline)
del after['issue_timeline'][-1][field]
with self.subTest(missing=field), self.assertRaises(fixture.mod.ValidationError):
fixture.verify_fixture(before, after, review, result, paths, self.contract())
def test_opt_in_version_and_complete_evidence_are_required(self):
baseline = self.snapshots()
fixture.verify_fixture(*baseline, self.contract())
for contract in (None, {'version': {'version': '1.27.3'}},
{'version': {'version': '1.27.3'}, 'source_issue_cross_reference_projection': False},
{'version': {'version': '1.27.2'}, 'source_issue_cross_reference_projection': True}):
with self.subTest(contract=contract), self.assertRaises(fixture.mod.ValidationError):
fixture.verify_fixture(*baseline, contract)
for value in (1, 'true', None, [], {}):
contract = self.contract(); contract['source_issue_cross_reference_projection'] = value
with self.subTest(value=value), self.assertRaises(fixture.mod.ValidationError):
fixture.verify_fixture(*baseline, contract)
for side in (0, 1):
for field in ('issue_timeline', 'pr_issue'):
args = copy.deepcopy(baseline); del args[side][field]
with self.subTest(side=side, missing=field), self.assertRaises(fixture.mod.ValidationError):
fixture.verify_fixture(*args, self.contract())
def test_no_time_tolerance_or_other_field_relaxation(self):
baseline = self.snapshots()
fixture.verify_fixture(*baseline, self.contract())
for section, field, value in [('issue', 'updated_at', '2026-10-03T01:24:02Z'),
('issue', 'title', 'modified'), ('issue', 'body', 'new contract'),
('pr_issue', 'updated_at', '2026-10-03T01:24:01Z'),
('pr_issue', 'comments', 4), ('pr_issue', 'comments', True),
('pr_issue', 'body', 'modified'), ('pr_issue', 'id', True),
('pr_issue', 'number', 8), ('pr_issue', 'state', 'closed')]:
args = copy.deepcopy(baseline); args[1][section][field] = value
with self.subTest(section=section, field=field), self.assertRaises(fixture.mod.ValidationError):
fixture.verify_fixture(*args, self.contract())
for mode in ('missing', 'duplicate', 'competing'):
args = copy.deepcopy(baseline); events = args[1]['issue_timeline']
if mode == 'missing':
events.clear()
else:
extra = copy.deepcopy(events[-1])
if mode == 'competing':
extra.update(id=103, type='comment')
events.append(extra)
with self.subTest(mode=mode), self.assertRaises(fixture.mod.ValidationError):
fixture.verify_fixture(*args, self.contract())
def test_existing_source_records_preserve_every_field_except_bound_pr_issue_projection(self):
before, after, review, result, paths = self.snapshots()
old = {'id': 90, 'type': 'pull_ref', 'body': '',
'ref_issue': copy.deepcopy(before['pr_issue']), 'user': {'id': 99}}
before['issue_timeline'].append(old)
after['issue_timeline'].insert(0, copy.deepcopy(old))
after['issue_timeline'][0]['ref_issue'] = copy.deepcopy(after['pr_issue'])
baseline = (before, after, review, result, paths)
fixture.verify_fixture(*baseline, self.contract())
for field in ('body', 'user', 'ref_issue', 'missing', 'order'):
args = copy.deepcopy(baseline)
if field == 'missing':
args[1]['issue_timeline'].pop(0)
elif field == 'order':
args[1]['issue_timeline'].reverse()
elif field == 'ref_issue':
args[1]['issue_timeline'][0]['ref_issue']['comments'] = 5
else:
args[1]['issue_timeline'][0][field] = 'tampered'
with self.subTest(field=field), self.assertRaises(fixture.mod.ValidationError):
fixture.verify_fixture(*args, self.contract())
args = copy.deepcopy(baseline)
args[0]['issue_timeline'].append({'id': 89, 'body': 'ordinary'})
args[1]['issue_timeline'].insert(1, {'id': 89, 'body': 'ordinary'})
fixture.verify_fixture(*args, self.contract())
args[1]['issue_timeline'][1]['body'] = 'changed'
with self.assertRaises(fixture.mod.ValidationError):
fixture.verify_fixture(*args, self.contract())
def test_source_reference_timestamp_does_not_apply_to_historical_reviews(self):
before, after, review, result, paths = self.snapshots()
old = copy.deepcopy(review)
old.update(id=10, body='previous', state='REQUEST_CHANGES', commit_id='d' * 40,
updated_at=before['pr']['updated_at'])
before['reviews'] = [old]
after['reviews'].insert(0, copy.deepcopy(old))
after['reviews'][0].update(official=False, dismissed=True, updated_at=review['submitted_at'])
contract = self.contract(); contract['supersession_same_author_only'] = True
fixture.verify_fixture(before, after, review, result, paths, contract)
after['reviews'][0]['updated_at'] = after['issue']['updated_at']
with self.assertRaises(fixture.mod.ValidationError):
fixture.verify_fixture(before, after, review, result, paths, contract)
def test_cli_uses_exact_reference_and_sanitizes_invalid_input(self):
from pathlib import Path
before, after, review, result, paths = self.snapshots()
with tempfile.TemporaryDirectory(dir='/opt/data/cache/scratch') as directory:
root = Path(directory)
data = {'before': before, 'after': after, 'exact-review': review, 'result': result,
'allowed-paths': sorted(paths), 'inline-comments': [], 'provider-contract': self.contract()}
command = [sys.executable, '-B', str(fixture.MODPATH), 'verify-publication',
'--reviewer-id', '42', '--expected-pr-url', before['pr']['html_url']]
for key, value in data.items():
path = root / (key + '.json')
path.write_text(json.dumps(value), encoding='utf-8')
command.extend(['--' + key, str(path)])
actual = subprocess.run(command, capture_output=True, text=True, timeout=15)
self.assertEqual(actual.returncode, 0, actual.stderr)
self.assertEqual(json.loads(actual.stdout)['source_issue_reference_ids'], [102])
after['issue_timeline'][0]['ref_comment']['body'] = 'sensitive-value-do-not-echo'
(root / 'after.json').write_text(json.dumps(after), encoding='utf-8')
rejected = subprocess.run(command, capture_output=True, text=True, timeout=15)
self.assertEqual(rejected.returncode, 2)
self.assertEqual(json.loads(rejected.stderr), {'error': 'invalid input'})
self.assertEqual(rejected.stdout, '')
self.assertNotIn('sensitive-value-do-not-echo', rejected.stdout + rejected.stderr)
def test_collector_rejects_bad_target_or_provider_before_any_read(self):
calls = []
def read_record(endpoint):
calls.append(endpoint)
self.fail('invalid collector request reached read callback')
def fetch_page(endpoint, page):
calls.append((endpoint, page))
self.fail('invalid collector request reached page callback')
for field, value in [('issue', True), ('owner', '../other'), ('repository', 'bad/name'),
('head', 'not-a-full-sha')]:
target = copy.deepcopy(fixture.result()['target']); target[field] = value
with self.subTest(field=field), self.assertRaises(fixture.mod.ValidationError):
fixture.mod.collect_publication_snapshot(read_record, fetch_page, target,
provider_contract=self.contract())
contract = self.contract(); contract['version']['version'] = 'other'
with self.assertRaises(fixture.mod.ValidationError):
fixture.mod.collect_publication_snapshot(read_record, fetch_page, fixture.result()['target'],
provider_contract=contract)
self.assertEqual(calls, [])
def test_collector_legacy_shape_and_complete_source_page_validation(self):
before, after, review, result, paths = self.snapshots()
prefix = '/repos/acme/repo'
records = {prefix + '/issues/3': before['issue'], prefix + '/pulls/7': before['pr'],
prefix + '/issues/7': before['pr_issue']}
calls = []
def fetch_page(endpoint, page):
calls.append((endpoint, page))
return {'items': [], 'total_count': 0, 'has_more': False}
legacy = fixture.mod.collect_publication_snapshot(records.__getitem__, fetch_page, result['target'])
self.assertEqual(set(legacy), {'issue', 'pr', 'issue_comments', 'pr_comments', 'reviews', 'timeline', 'files'})
self.assertNotIn((prefix + '/issues/3/timeline', 1), calls)
for envelope in ({'items': [{'id': 1}, {'id': 1}], 'total_count': 2, 'has_more': False},
{'items': [], 'total_count': 1, 'has_more': True},
{'items': None, 'has_more': False}):
def bad_page(endpoint, page):
if endpoint == prefix + '/issues/3/timeline':
return envelope
return {'items': [], 'total_count': 0, 'has_more': False}
with self.subTest(envelope=envelope), self.assertRaises(fixture.mod.ValidationError):
fixture.mod.collect_publication_snapshot(records.__getitem__, bad_page, result['target'],
provider_contract=self.contract())
def test_collector_reads_source_timeline_and_pr_issue_through_readonly_callbacks(self):
before, after, review, result, paths = self.snapshots()
mod = fixture.mod
self.assertTrue(callable(getattr(mod, 'collect_publication_snapshot', None)),
'canonical read-only publication snapshot collector missing')
prefix = '/repos/acme/repo'
endpoints = {'issue': '/issues/3', 'pr': '/pulls/7', 'pr_issue': '/issues/7',
'issue_comments': '/issues/3/comments', 'pr_comments': '/issues/7/comments',
'reviews': '/pulls/7/reviews', 'timeline': '/issues/7/timeline',
'issue_timeline': '/issues/3/timeline', 'files': '/pulls/7/files'}
captured = []
for snapshot in (before, after):
records = {prefix + endpoints[k]: v for k, v in snapshot.items()}
calls = []
def read_record(endpoint):
calls.append(('GET', endpoint))
return copy.deepcopy(records[endpoint])
def fetch_page(endpoint, page):
calls.append(('GET-page', endpoint, page))
items = records[endpoint]
return {'items': copy.deepcopy(items), 'total_count': len(items), 'has_more': False}
out = mod.collect_publication_snapshot(read_record, fetch_page, result['target'],
provider_contract=self.contract())
self.assertEqual(out, snapshot)
self.assertIn(('GET', prefix + '/issues/7'), calls)
self.assertIn(('GET-page', prefix + '/issues/3/timeline', 1), calls)
self.assertTrue(all(call[0].startswith('GET') for call in calls))
captured.append(out)
self.assertEqual(fixture.verify_fixture(*captured, review, result, paths,
self.contract())['source_issue_reference_ids'], [102])
if __name__ == '__main__':
unittest.main()