[verified] Fix native review PR Issue counter projection

This commit is contained in:
2026-10-14 06:20:07 +00:00
parent 6cddc26bb7
commit f5ef03418e
3 changed files with 51 additions and 4 deletions
@@ -1,7 +1,7 @@
--- ---
name: gitea-implementation-spec-review name: gitea-implementation-spec-review
description: 승인된 계약에 대해 Gitea 구현 PR 적합성을 검토한다. description: 승인된 계약에 대해 Gitea 구현 PR 적합성을 검토한다.
version: 0.2.7 version: 0.2.8
author: genichin, Hermes Agent author: genichin, Hermes Agent
license: MIT license: MIT
platforms: [linux] platforms: [linux]
@@ -247,7 +247,7 @@ terminal(command="python3 harness/agents/spec/skills/gitea-implementation-spec-r
- Opt-in일 때 initial baseline, write 직전, after 및 read-only replay snapshot에 기존 7개 field와 함께 `issue_timeline`, `pr_issue`를 반드시 수집한다. 각각 exact `GET /repos/<owner>/<repository>/issues/<source-issue>/timeline`의 complete collection과 `GET /repos/<owner>/<repository>/issues/<pull-number>`의 독립 native Issue record다. `pr_issue`는 PullRequest record와 다른 provider DB ID를 가질 수 있으므로 두 ID를 동일하다고 가정하지 않는다. Endpoint는 기존 API adapter 기준 상대 경로이며 host·설치 prefix는 독립 인증된 canonical PR URL을 유지한다. Raw response와 page metadata를 먼저 보존하고 정렬·재작성하지 않는다. 누락·중복·불완전 pagination·비지원 버전은 fail-closed한다. - Opt-in일 때 initial baseline, write 직전, after 및 read-only replay snapshot에 기존 7개 field와 함께 `issue_timeline`, `pr_issue`를 반드시 수집한다. 각각 exact `GET /repos/<owner>/<repository>/issues/<source-issue>/timeline`의 complete collection과 `GET /repos/<owner>/<repository>/issues/<pull-number>`의 독립 native Issue record다. `pr_issue`는 PullRequest record와 다른 provider DB ID를 가질 수 있으므로 두 ID를 동일하다고 가정하지 않는다. Endpoint는 기존 API adapter 기준 상대 경로이며 host·설치 prefix는 독립 인증된 canonical PR URL을 유지한다. Raw response와 page metadata를 먼저 보존하고 정렬·재작성하지 않는다. 누락·중복·불완전 pagination·비지원 버전은 fail-closed한다.
- 게시 후 Source timeline에는 baseline 대비 신규 event가 정확히 하나이고 마지막 항목이어야 한다. `type=comment_ref`, stable author와 전체 user record, Source Issue URL 및 event URL, 빈 body와 `ref_action=none`, integer `review_id=0`을 검증한다. `ref_issue` 전체 JSON은 독립 after `pr_issue`와 exact-match해야 한다. `ref_comment.id`는 검증된 신규 PR review timeline event ID이며 nested author·body·PR URL·comment URL·created/updated timestamp와 빈 attachments를 대조한다. 이 연결은 이미 검증된 exact review의 ID·head·body·state에 귀속된다. - 게시 후 Source timeline에는 baseline 대비 신규 event가 정확히 하나이고 마지막 항목이어야 한다. `type=comment_ref`, stable author와 전체 user record, Source Issue URL 및 event URL, 빈 body와 `ref_action=none`, integer `review_id=0`을 검증한다. `ref_issue` 전체 JSON은 독립 after `pr_issue`와 exact-match해야 한다. `ref_comment.id`는 검증된 신규 PR review timeline event ID이며 nested author·body·PR URL·comment URL·created/updated timestamp와 빈 attachments를 대조한다. 이 연결은 이미 검증된 exact review의 ID·head·body·state에 귀속된다.
- Source Issue의 `updated_at`은 그 신규 reference event의 동일한 `created_at`/`updated_at`에 정확히 일치해야 하며 event는 review event보다 이전일 수 없다. 허용 timestamp는 Source Issue에만 적용한다. PR·PR Issue·historical review timestamp 규칙, Source Issue body·title·state·labels·assignees·milestone·normal comments와 기존 scope 보호를 완화하지 않는다. 초 단위 허용 오차나 timestamp 일괄 제외를 추가하지 않는다. - Source Issue의 `updated_at`은 그 신규 reference event의 동일한 `created_at`/`updated_at`에 정확히 일치해야 하며 event는 review event보다 이전일 수 없다. 허용 timestamp는 Source Issue에만 적용한다. PR·PR Issue·historical review timestamp 규칙, Source Issue body·title·state·labels·assignees·milestone·normal comments와 기존 scope 보호를 완화하지 않는다. 초 단위 허용 오차나 timestamp 일괄 제외를 추가하지 않는다.
- 독립 before/after `pr_issue`는 exact target·author·open/unmerged 상태를 확인하고, 기존 모든 field를 보존하되 verified PR review event 시각의 `updated_at` 및 native conversation `comments`의 정확히 1 증가만 허용한다. Source timeline의 기존 event는 ID·순서·type·author·body·ref_comment와 나머지 field를 보존한다. 단, provider가 참조 대상의 현재 Issue record를 serialize하므로 기존 event의 `ref_issue`만 달라질 수 있다. 이 nested projection은 old/new 값 전체가 독립 before/after `pr_issue`와 각각 exact-match할 때만 허용한다. 다른 Issue 참조, 임의 field 변경, baseline 제거·순서 변경·추가 경쟁 event는 차단한다. - 독립 before/after `pr_issue`는 exact target·author·open/unmerged 상태를 확인하고, verified PR review event 시각의 `updated_at`을 제외한 모든 field를 보존한다. 일반 댓글 수인 `pr_issue.comments`는 before/after 모두 boolean이 아닌 0 이상의 integer이며 정확히 같아야 한다. Native top-level `CommentTypeReview`는 일반 댓글 수를 증가시키지 않는다(`models/issues/comment.go`의 `updateCommentInfos`: `UpdateIssueNumComments`는 `CommentTypeComment`/제출된 `CommentTypeCode` 경로에만 적용). 별도 PullRequest record의 `pr.review_comments`는 검증한 신규 review 하나에 대해 정확히 1 증가해야 하며, 이 검증은 유지한다. 두 카운터를 혼동하거나 일반 댓글 증가·감소·누락·잘못된 타입을 projection으로 허용하지 않는다. Source timeline의 기존 event는 ID·순서·type·author·body·ref_comment와 나머지 field를 보존한다. 단, provider가 참조 대상의 현재 Issue record를 serialize하므로 기존 event의 `ref_issue`만 달라질 수 있다. 이 nested projection은 old/new 값 전체가 독립 before/after `pr_issue`와 각각 exact-match할 때만 허용한다. 다른 Issue 참조, 임의 field 변경, baseline 제거·순서 변경·추가 경쟁 event는 차단한다.
- `verify-publication`의 CLI 인자는 그대로이며 opt-in provider-contract와 위 9-field before/after snapshot을 전달한다. 성공 출력의 `source_issue_reference_ids`는 검증한 신규 Source event ID 하나다. Opt-in 없는 legacy snapshot은 기존 strict 규칙을 유지하고 확대 field를 자동 채택하지 않는다. Write 직전에는 확대 snapshot 전체를 `check-unchanged`로 baseline과 대조하고, 실패 후 추가 POST를 하지 않는다. - `verify-publication`의 CLI 인자는 그대로이며 opt-in provider-contract와 위 9-field before/after snapshot을 전달한다. 성공 출력의 `source_issue_reference_ids`는 검증한 신규 Source event ID 하나다. Opt-in 없는 legacy snapshot은 기존 strict 규칙을 유지하고 확대 field를 자동 채택하지 않는다. Write 직전에는 확대 snapshot 전체를 `check-unchanged`로 baseline과 대조하고, 실패 후 추가 POST를 하지 않는다.
- 회귀 검증: `PYTHONDONTWRITEBYTECODE=1 python3 -m unittest harness.tests.spec.test_source_issue_reference_projection -v`. Collector → snapshot → CLI verifier의 로컬 검증과 saved-wire compatibility replay는 실제 새 게시 성공이 아니다. 과거 before evidence에 Source timeline 또는 독립 PR Issue가 없으면 재구성한 입력으로 과거 `partial`을 소급 PASS로 바꾸지 않는다. - 회귀 검증: `PYTHONDONTWRITEBYTECODE=1 python3 -m unittest harness.tests.spec.test_source_issue_reference_projection -v`. Collector → snapshot → CLI verifier의 로컬 검증과 saved-wire compatibility replay는 실제 새 게시 성공이 아니다. 과거 before evidence에 Source timeline 또는 독립 PR Issue가 없으면 재구성한 입력으로 과거 `partial`을 소급 PASS로 바꾸지 않는다.
@@ -603,7 +603,9 @@ def _source_issue_reference(before, after, event, target, pr_url, reviewer_id, c
pr_issue['updated_at'] not in causal_times): pr_issue['updated_at'] not in causal_times):
_fail('PR Issue drift') _fail('PR Issue drift')
old_count, count = old_pr_issue.get('comments'), pr_issue.get('comments') old_count, count = old_pr_issue.get('comments'), pr_issue.get('comments')
if type(old_count) is not int or old_count < 0 or type(count) is not int or count != old_count + 1: # CommentTypeReview does not increment Issue.NumComments (Gitea 1.27.3).
# The independent PR review_comments +1 check remains in verify_publication.
if type(old_count) is not int or old_count < 0 or type(count) is not int or count != old_count:
_fail('PR Issue count projection mismatch') _fail('PR Issue count projection mismatch')
old_events = _index(before['issue_timeline']) old_events = _index(before['issue_timeline'])
events = _index(after['issue_timeline']) events = _index(after['issue_timeline'])
@@ -24,7 +24,8 @@ class SourceIssueReferenceProjectionTests(unittest.TestCase):
'pull_request': {'merged': False, 'html_url': url}} 'pull_request': {'merged': False, 'html_url': url}}
before['pr_issue'] = pr_issue before['pr_issue'] = pr_issue
after['pr_issue'] = copy.deepcopy(pr_issue) after['pr_issue'] = copy.deepcopy(pr_issue)
after['pr_issue'].update(comments=3, updated_at=review['submitted_at']) # Native top-level reviews change review_comments, not ordinary comments.
after['pr_issue'].update(updated_at=review['submitted_at'])
event = after['timeline'][0] event = after['timeline'][0]
event['html_url'] = url + '#issuecomment-101' event['html_url'] = url + '#issuecomment-101'
event['issue_url'] = '' event['issue_url'] = ''
@@ -57,6 +58,50 @@ class SourceIssueReferenceProjectionTests(unittest.TestCase):
with self.assertRaisesRegex(fixture.mod.ValidationError, 'timestamp projection mismatch'): with self.assertRaisesRegex(fixture.mod.ValidationError, 'timestamp projection mismatch'):
fixture.verify_fixture(before, after, review, result, paths, self.contract()) 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): def test_wrong_reference_identity_payload_or_timestamp_is_rejected(self):
baseline = self.snapshots() baseline = self.snapshots()
fixture.verify_fixture(*baseline, self.contract()) fixture.verify_fixture(*baseline, self.contract())