Merge spec reference readback fix after impl operation architecture integration
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: gitea-implementation-spec-review
|
name: gitea-implementation-spec-review
|
||||||
description: 승인된 계약에 대해 Gitea 구현 PR 적합성을 검토한다.
|
description: 승인된 계약에 대해 Gitea 구현 PR 적합성을 검토한다.
|
||||||
version: 0.2.6
|
version: 0.2.7
|
||||||
author: genichin, Hermes Agent
|
author: genichin, Hermes Agent
|
||||||
license: MIT
|
license: MIT
|
||||||
platforms: [linux]
|
platforms: [linux]
|
||||||
@@ -236,9 +236,21 @@ terminal(command="python3 harness/agents/spec/skills/gitea-implementation-spec-r
|
|||||||
- `requested_reviewers_presentation_order=true`는 `version.version="1.27.3"`에 결합된 opt-in이다. Write 전에 main이 authenticated version 및 해당 provider의 latest non-dismissed review/update-time 기반 presentation 순서 근거를 확인·보존한 경우에만 제공한다. 버전 문자열이나 caller flag 자체는 인증·provider 동작의 증명이 아니다. 이 flag에서만 `requested_reviewers`를 duplicate-free positive stable account ID로 비교한다: 기존 membership과 각 record의 모든 nested field·type을 정확히 보존하고, 추가는 verified review author ID 하나만 허용하며 그 새 record 전체 JSON이 exact review의 `user` record와 정확히 같아야 한다(이미 목록에 있으면 추가 없이 순서 교환 가능). 기존 reviewer 제거, 다른 account 추가, 중복/invalid ID, record field 변경은 차단한다. Flag가 없거나 false이면 기존 ordered 비교를 유지한다. Raw before/after snapshot은 정렬·재작성하지 않으며 criteria, historical reviews, timeline, comments 및 다른 배열의 순서 계약을 완화하지 않는다. Publication 실패 후 POST 재시도 금지와 exact target/head/body/author/cardinality 검증은 그대로 유지한다.
|
- `requested_reviewers_presentation_order=true`는 `version.version="1.27.3"`에 결합된 opt-in이다. Write 전에 main이 authenticated version 및 해당 provider의 latest non-dismissed review/update-time 기반 presentation 순서 근거를 확인·보존한 경우에만 제공한다. 버전 문자열이나 caller flag 자체는 인증·provider 동작의 증명이 아니다. 이 flag에서만 `requested_reviewers`를 duplicate-free positive stable account ID로 비교한다: 기존 membership과 각 record의 모든 nested field·type을 정확히 보존하고, 추가는 verified review author ID 하나만 허용하며 그 새 record 전체 JSON이 exact review의 `user` record와 정확히 같아야 한다(이미 목록에 있으면 추가 없이 순서 교환 가능). 기존 reviewer 제거, 다른 account 추가, 중복/invalid ID, record field 변경은 차단한다. Flag가 없거나 false이면 기존 ordered 비교를 유지한다. Raw before/after snapshot은 정렬·재작성하지 않으며 criteria, historical reviews, timeline, comments 및 다른 배열의 순서 계약을 완화하지 않는다. Publication 실패 후 POST 재시도 금지와 exact target/head/body/author/cardinality 검증은 그대로 유지한다.
|
||||||
- 예시 provider-contract JSON: `{"version":{"version":"1.27.3"},"requested_reviewers_presentation_order":true}`. 기존 supersession/retouch/official 정책이 필요하면 각각 independently verified 값을 명시적으로 유지한다. 정본 계약 위치는 이 문서이며 기존 문서를 update-in-place한다; 회차별 artifact에서 별도 verifier나 snapshot normalization으로 우회하지 않는다.
|
- 예시 provider-contract JSON: `{"version":{"version":"1.27.3"},"requested_reviewers_presentation_order":true}`. 기존 supersession/retouch/official 정책이 필요하면 각각 independently verified 값을 명시적으로 유지한다. 정본 계약 위치는 이 문서이며 기존 문서를 update-in-place한다; 회차별 artifact에서 별도 verifier나 snapshot normalization으로 우회하지 않는다.
|
||||||
- `check-unchanged --before <file> --after <file>`는 read-only replay의 exact snapshot 동일성을 확인한다. Helper 성공은 호출자가 실제 write를 하지 않았다는 증명이나 최종 리뷰 완료를 단독으로 의미하지 않는다.
|
- `check-unchanged --before <file> --after <file>`는 read-only replay의 exact snapshot 동일성을 확인한다. Helper 성공은 호출자가 실제 write를 하지 않았다는 증명이나 최종 리뷰 완료를 단독으로 의미하지 않는다.
|
||||||
- Helper는 credential을 발견하거나 network·외부 write를 수행하지 않고 acceptance 의미·evidence 유효성·verdict를 결정하지 않는다. 실제 secret 부재는 main이 별도로 확인하며 알려진 민감값은 Python API의 `sensitive_values`로 메모리 안에서만 전달한다. 입력 오류는 nonzero로 중단하고 입력 본문·credential을 error에 출력하지 않는다.
|
- Helper는 credential을 발견하거나 자체 network client·외부 write를 제공하지 않고 acceptance 의미·evidence 유효성·verdict를 결정하지 않는다. Read-only collector가 호출하는 authenticated GET callback과 provider envelope 변환은 main의 기존 adapter 책임이다. 실제 secret 부재는 main이 별도로 확인하며 알려진 민감값은 Python API의 `sensitive_values`로 메모리 안에서만 전달한다. 입력 오류는 nonzero로 중단하고 입력 본문·credential을 error에 출력하지 않는다.
|
||||||
- 회귀 검증은 `terminal(command="PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s harness/tests/spec -p 'test_implementation_spec_review_support.py' -v")`로 실행한다. Schema/body/provider 처리 변경 시 같은 schema의 저장된 실제 wire artifact로 render·parse byte 일치와 publication·read-only replay도 검증한다. Helper 검증을 위해 새 외부 write를 하지 않으며 과거 artifact replay를 fresh authenticated readback으로 보고하지 않는다.
|
- 회귀 검증은 `terminal(command="PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s harness/tests/spec -p 'test_implementation_spec_review_support.py' -v")`로 실행한다. Schema/body/provider 처리 변경 시 같은 schema의 저장된 실제 wire artifact로 render·parse byte 일치와 publication·read-only replay도 검증한다. Helper 검증을 위해 새 외부 write를 하지 않으며 과거 artifact replay를 fresh authenticated readback으로 보고하지 않는다.
|
||||||
|
|
||||||
|
### Source Issue 자동 참조 projection
|
||||||
|
|
||||||
|
`source_issue_cross_reference_projection=true`는 `version.version="1.27.3"`에 결합된 single native create 전용 opt-in이다. Write 전에 main이 authenticated version과 matching provider source의 `Comment.AddCrossReferences → createCrossReferences → CreateComment → UpdateIssueCols(updated_unix)` 및 `ToTimelineComment → ToIssue/ToComment` serializer 경로를 확인·보존한 경우에만 사용한다. 리뷰 본문의 Source Issue 참조 때문에 provider가 생성한 `comment_ref` 활동은 직접 Issue mutation API 호출과 구분하며, 시간 근접성이나 version/flag의 자기 선언만으로 인과를 인정하지 않는다.
|
||||||
|
|
||||||
|
- 정본 snapshot 경로는 `collect_publication_snapshot(read_record, fetch_page, target, *, provider_contract=None, page_size=50, max_pages=10000)`다. 기존 authenticated adapter의 `read_record(endpoint)`는 exact native GET object를, `fetch_page(endpoint, page)`는 실제 provider metadata에 결합된 `items`, `total_count`, `has_more` envelope를 반환한다. Collector는 기존 `collect_pages`를 사용하며 별도 인증·POST·retry·publisher를 제공하지 않는다. Provider flag가 없거나 false이면 기존 7-field snapshot을 유지한다.
|
||||||
|
- 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 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는 차단한다.
|
||||||
|
- `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로 바꾸지 않는다.
|
||||||
|
|
||||||
## 절차
|
## 절차
|
||||||
|
|
||||||
1. Exact target identifiers를 검증하고 `operations/repository-cache.md`를 새로 읽는다.
|
1. Exact target identifiers를 검증하고 `operations/repository-cache.md`를 새로 읽는다.
|
||||||
@@ -256,7 +268,7 @@ terminal(command="python3 harness/agents/spec/skills/gitea-implementation-spec-r
|
|||||||
12. Verdict 규칙으로 하나의 strict result artifact와 canonical review body를 trusted workflow directory에 생성한다.
|
12. Verdict 규칙으로 하나의 strict result artifact와 canonical review body를 trusted workflow directory에 생성한다.
|
||||||
13. Exact key/type, duplicate key, marker field, digest, deterministic correlation, criterion cardinality, path binding, verdict/state 조합, credential 부재와 render → parse → render byte equality를 검증한다.
|
13. Exact key/type, duplicate key, marker field, digest, deterministic correlation, criterion cardinality, path binding, verdict/state 조합, credential 부재와 render → parse → render byte equality를 검증한다.
|
||||||
14. 여기까지는 read-only preflight다. Exact review 요청이 native review write 권한을 부여하지 않았거나 preflight가 완전하지 않으면 종료하며 review create 또는 다른 mutation을 수행하지 않는다.
|
14. 여기까지는 read-only preflight다. Exact review 요청이 native review write 권한을 부여하지 않았거나 preflight가 완전하지 않으면 종료하며 review create 또는 다른 mutation을 수행하지 않는다.
|
||||||
15. Write 직전에 Issue 전체 필드·comments, PR details·base·head·changed files와 전체 review baseline을 다시 읽는다. Contract snapshot, base target, head SHA, changed-file set 또는 baseline이 drift하면 native review를 쓰지 않는다.
|
15. Write 직전에 Issue 전체 필드·comments, PR details·base·head·changed files와 전체 review baseline을 다시 읽는다. Source Issue 자동 참조 opt-in을 사용하면 위 정본 collector로 initial/write 직전/after/replay의 Source timeline과 독립 PR Issue도 수집하며, write 전 확대 baseline의 exact 불변성을 확인한다. Contract snapshot, base target, head SHA, changed-file set 또는 baseline이 drift하면 native review를 쓰지 않는다.
|
||||||
`commit.files`처럼 순서에 의미가 없는 collection은 filename 중복을 먼저 거부하고 filename·status 기준으로 정규화해 비교한다. 순서 차이만 제외하고 모든 항목·필드 및 exact blob/hunk는 보존·대조하며, 모든 배열을 일괄 set 처리하지 않는다. Criteria 순서와 의미 있는 event 순서는 유지한다.
|
`commit.files`처럼 순서에 의미가 없는 collection은 filename 중복을 먼저 거부하고 filename·status 기준으로 정규화해 비교한다. 순서 차이만 제외하고 모든 항목·필드 및 exact blob/hunk는 보존·대조하며, 모든 배열을 일괄 set 처리하지 않는다. Criteria 순서와 의미 있는 event 순서는 유지한다.
|
||||||
16. Stable reviewer account ID, head, contract digest와 result digest가 모두 같은 canonical review가 정확히 하나 이미 있으면 exact review와 review comments를 authoritative하게 readback한다. Verdict-compatible 최종 state와 stale/dismissed 상태까지 검증된 경우에만 write 없는 no-op으로 종료한다. `PENDING`이면 완료나 새 create로 진행하지 않고 절차 19의 복구 조건을 적용한다. 같은 binding의 다른 결과 또는 중복 review가 있으면 fail-closed한다.
|
16. Stable reviewer account ID, head, contract digest와 result digest가 모두 같은 canonical review가 정확히 하나 이미 있으면 exact review와 review comments를 authoritative하게 readback한다. Verdict-compatible 최종 state와 stale/dismissed 상태까지 검증된 경우에만 write 없는 no-op으로 종료한다. `PENDING`이면 완료나 새 create로 진행하지 않고 절차 19의 복구 조건을 적용한다. 같은 binding의 다른 결과 또는 중복 review가 있으면 fail-closed한다.
|
||||||
17. 호출 전에 선택한 adapter의 실제 요청 schema와 endpoint별 성공 status를 검증하며 확인할 수 없으면 게시하지 않는다. 비정본·변경된 readback helper를 사용하면 아래 비정본·변경된 readback helper 호출 계약을 먼저 적용한다. Native review create를 `body=<canonical body>`, `comments=[]`, `commit_id=<exact head SHA>`와 명시적 verdict-compatible 요청 필드로 정확히 한 번 호출한다. MCP는 tool schema의 `state` 등 실제 인자를 사용하고, 직접 REST는 서버 schema의 `event=<APPROVED|REQUEST_CHANGES|COMMENT>`를 사용한다. MCP 인자를 REST payload에 복사하거나 모든 POST의 성공 status를 `201`로 가정하지 않는다. REST review endpoint의 `200` 등 확인된 성공 status만 인정하고 authoritative 응답의 `state`는 기존 verdict mapping과 exact-match해야 한다.
|
17. 호출 전에 선택한 adapter의 실제 요청 schema와 endpoint별 성공 status를 검증하며 확인할 수 없으면 게시하지 않는다. 비정본·변경된 readback helper를 사용하면 아래 비정본·변경된 readback helper 호출 계약을 먼저 적용한다. Native review create를 `body=<canonical body>`, `comments=[]`, `commit_id=<exact head SHA>`와 명시적 verdict-compatible 요청 필드로 정확히 한 번 호출한다. MCP는 tool schema의 `state` 등 실제 인자를 사용하고, 직접 REST는 서버 schema의 `event=<APPROVED|REQUEST_CHANGES|COMMENT>`를 사용한다. MCP 인자를 REST payload에 복사하거나 모든 POST의 성공 status를 `201`로 가정하지 않는다. REST review endpoint의 `200` 등 확인된 성공 status만 인정하고 authoritative 응답의 `state`는 기존 verdict mapping과 exact-match해야 한다.
|
||||||
@@ -264,6 +276,7 @@ terminal(command="python3 harness/agents/spec/skills/gitea-implementation-spec-r
|
|||||||
새 최종 review는 `stale=false`, `dismissed=false`여야 한다. `official`은 고정된 `false`를 요구하지 않고 authoritative provider 계약·조회된 권한·review 정책과 대조해 판정하며, 그 값만으로 작성자·본문·head·최종 state 검증을 대체하지 않는다. Baseline 변경은 절차 20의 검증된 파생 projection만 제외하고 보존한다.
|
새 최종 review는 `stale=false`, `dismissed=false`여야 한다. `official`은 고정된 `false`를 요구하지 않고 authoritative provider 계약·조회된 권한·review 정책과 대조해 판정하며, 그 값만으로 작성자·본문·head·최종 state 검증을 대체하지 않는다. Baseline 변경은 절차 20의 검증된 파생 projection만 제외하고 보존한다.
|
||||||
19. Review create가 timeout·오류 또는 malformed response이면 blind retry하지 않는다. Complete review collection과 candidate review comments를 한 번 authoritative reconciliation해 baseline 대비 exact result가 하나이고 verdict-compatible 최종 state와 inline comment 부재가 확인될 때만 제출 완료로 판정한다. Exact owned `PENDING` review 하나가 확인되면 그 ID를 보존하고 partial publication으로 기록한다. 같은 review 제출이 승인된 경우에만, submit schema·성공 status 및 identity·Issue contract·PR base/head·changed files·review baseline·canonical body를 새로 검증하고(이미 인과가 증명된 해당 review의 파생 projection만 제외), 동일 ID를 명시적 verdict로 한 번 submit한 뒤 절차 18과 20의 readback을 적용한다. 새 create는 하지 않으며 submit 응답이 불확실하면 동일 ID를 authoritative readback해 최종 제출을 확인하거나 uncertain/partial로 중단한다. Candidate가 없거나 여러 개·경쟁 review·drift가 있으면 복구 write 없이 중단한다.
|
19. Review create가 timeout·오류 또는 malformed response이면 blind retry하지 않는다. Complete review collection과 candidate review comments를 한 번 authoritative reconciliation해 baseline 대비 exact result가 하나이고 verdict-compatible 최종 state와 inline comment 부재가 확인될 때만 제출 완료로 판정한다. Exact owned `PENDING` review 하나가 확인되면 그 ID를 보존하고 partial publication으로 기록한다. 같은 review 제출이 승인된 경우에만, submit schema·성공 status 및 identity·Issue contract·PR base/head·changed files·review baseline·canonical body를 새로 검증하고(이미 인과가 증명된 해당 review의 파생 projection만 제외), 동일 ID를 명시적 verdict로 한 번 submit한 뒤 절차 18과 20의 readback을 적용한다. 새 create는 하지 않으며 submit 응답이 불확실하면 동일 ID를 authoritative readback해 최종 제출을 확인하거나 uncertain/partial로 중단한다. Candidate가 없거나 여러 개·경쟁 review·drift가 있으면 복구 write 없이 중단한다.
|
||||||
20. Source Issue와 PR을 다시 읽는다. Body·title·state·labels·assignees·milestone·comments, PR base·head·changed files·merge state와 source content는 불변이어야 한다. 새 create는 baseline 대비 신규 review record가 정확히 하나여야 하고 기존 `PENDING` 복구는 추가 record 없이 같은 ID를 유지해야 하며, record 수와 timeline event 수를 구분한다. 단, Gitea가 exact native review create 또는 승인된 동일 ID submit의 파생 projection으로만 다음을 변경한 경우는 허용한다: Issue와 PR `updated_at`이 verified review `submitted_at` 또는 동일 review ID·author의 verified create/submit timeline event timestamp로 갱신됨, PR `review_comments`가 top-level review 생성으로 baseline보다 정확히 1 증가함(동일 ID submit 복구 시 추가 증가는 확인된 provider 계약과 해당 review timeline event로 인과가 증명된 경우에만 허용), `requested_reviewers`에 verified review author stable account ID가 정확히 추가됨. `submitted_at`이 draft 생성 시각을 유지할 수 있으므로 최종 state와 submit event를 별도로 검증한다. `comments: []` 계약은 projection count와 별개로 authoritative `get_review_comments`가 0개여야 한다. 이 파생 변경과 아래의 제한된 기존 review 대체 projection은 각 write 전후 snapshot과 exact review ID·author·timestamp로 인과를 증명해야 하며 허용 목록 밖의 field 변경, 기존 reviewer 제거, 추가 account 또는 승인되지 않은 write call이 있으면 fail-closed한다. 검증된 submit은 별도 write로 기록하되 provider 파생 field 갱신을 직접 Issue/PR mutation으로 보고하지 않는다.
|
20. Source Issue와 PR을 다시 읽는다. Body·title·state·labels·assignees·milestone·comments, PR base·head·changed files·merge state와 source content는 불변이어야 한다. 새 create는 baseline 대비 신규 review record가 정확히 하나여야 하고 기존 `PENDING` 복구는 추가 record 없이 같은 ID를 유지해야 하며, record 수와 timeline event 수를 구분한다. 단, Gitea가 exact native review create 또는 승인된 동일 ID submit의 파생 projection으로만 다음을 변경한 경우는 허용한다: Issue와 PR `updated_at`이 verified review `submitted_at` 또는 동일 review ID·author의 verified create/submit timeline event timestamp로 갱신됨, PR `review_comments`가 top-level review 생성으로 baseline보다 정확히 1 증가함(동일 ID submit 복구 시 추가 증가는 확인된 provider 계약과 해당 review timeline event로 인과가 증명된 경우에만 허용), `requested_reviewers`에 verified review author stable account ID가 정확히 추가됨. `submitted_at`이 draft 생성 시각을 유지할 수 있으므로 최종 state와 submit event를 별도로 검증한다. `comments: []` 계약은 projection count와 별개로 authoritative `get_review_comments`가 0개여야 한다. 이 파생 변경과 아래의 제한된 기존 review 대체 projection은 각 write 전후 snapshot과 exact review ID·author·timestamp로 인과를 증명해야 하며 허용 목록 밖의 field 변경, 기존 reviewer 제거, 추가 account 또는 승인되지 않은 write call이 있으면 fail-closed한다. 검증된 submit은 별도 write로 기록하되 provider 파생 field 갱신을 직접 Issue/PR mutation으로 보고하지 않는다.
|
||||||
|
Source Issue의 자동 `comment_ref` timestamp와 기존 Source event의 nested `ref_issue` projection은 위의 Source Issue 자동 참조 projection 계약을 write 전에 확인하고 정본 helper가 exact event·review 연결과 독립 PR Issue readback을 검증한 경우에만 허용한다. 이 event 시각을 PR 또는 historical review에 적용하지 않으며 나머지 field·normal comments·baseline event 보존과 추가 write 금지는 유지한다.
|
||||||
`requested_reviewers` 순서 변경은 위의 `requested_reviewers_presentation_order` 계약을 write 전에 확인한 경우에만 presentation-only로 허용한다. Stable ID 기준으로 모든 기존 record를 정확히 보존하며 이미 포함된 게시자의 위치 변경 또는 게시자 하나의 추가와 함께 발생한 기존 membership의 순서 변경만 제외한다. Raw snapshots를 유지하고, 기존 reviewer 제거·다른 account 추가·shared record 변경과 다른 배열의 순서 변경은 이 계약으로 허용하지 않는다.
|
`requested_reviewers` 순서 변경은 위의 `requested_reviewers_presentation_order` 계약을 write 전에 확인한 경우에만 presentation-only로 허용한다. Stable ID 기준으로 모든 기존 record를 정확히 보존하며 이미 포함된 게시자의 위치 변경 또는 게시자 하나의 추가와 함께 발생한 기존 membership의 순서 변경만 제외한다. Raw snapshots를 유지하고, 기존 reviewer 제거·다른 account 추가·shared record 변경과 다른 배열의 순서 변경은 이 계약으로 허용하지 않는다.
|
||||||
기존 review 대체 projection은 write 전에 authoritative provider 계약으로 대체 조건·대상·필드를 확인해 기록한 경우에만 허용한다. 동일 PR·동일 stable author의 baseline상 `official=true`, `dismissed=false`인 이전 review에서 `official: true→false`, `dismissed: false→true`, 해당 native create/submit event 시각으로의 `updated_at` 변경만 허용하며, 영향받은 모든 ID를 읽어 기존 ID·body·commit·state·author·stale와 나머지 필드 및 record 보존을 검증한다. 신규 review/event cardinality와 해당 write의 인과도 별도로 확인한다. Timestamp 일치나 과거 관측만으로 대체를 추정하지 않으며 다른 작성자·다른 필드 변경, 계약 미확인 또는 인과 불명은 `partial`/fail-closed로 기록하고 추가 write를 중단한다. 기존 flag를 직접 복원·dismiss하거나 과거 실행 결과를 소급 성공으로 바꾸지 않는다.
|
기존 review 대체 projection은 write 전에 authoritative provider 계약으로 대체 조건·대상·필드를 확인해 기록한 경우에만 허용한다. 동일 PR·동일 stable author의 baseline상 `official=true`, `dismissed=false`인 이전 review에서 `official: true→false`, `dismissed: false→true`, 해당 native create/submit event 시각으로의 `updated_at` 변경만 허용하며, 영향받은 모든 ID를 읽어 기존 ID·body·commit·state·author·stale와 나머지 필드 및 record 보존을 검증한다. 신규 review/event cardinality와 해당 write의 인과도 별도로 확인한다. Timestamp 일치나 과거 관측만으로 대체를 추정하지 않으며 다른 작성자·다른 필드 변경, 계약 미확인 또는 인과 불명은 `partial`/fail-closed로 기록하고 추가 write를 중단한다. 기존 flag를 직접 복원·dismiss하거나 과거 실행 결과를 소급 성공으로 바꾸지 않는다.
|
||||||
별도로, 동일 PR·동일 stable author의 baseline상 이미 `dismissed=true`인 이전 approve/reject review는 서버 버전에 결합된 authoritative provider 계약으로 bulk-update 대상과 timestamp 동작을 write 전에 확인한 경우에만 해당 native create/submit event에 인과가 증명된 `updated_at` 갱신만 허용한다. 기존 `official`·`dismissed` 값과 ID·body·commit·state·author·stale 및 모든 나머지 필드·record는 그대로 보존하고 영향받은 모든 ID와 신규 review/event cardinality를 readback한다. Timestamp 일치만으로 인과를 추정하지 않으며 계약·인과가 미확인되면 write 전 차단하거나 write 후 `partial`/fail-closed로 중단한다.
|
별도로, 동일 PR·동일 stable author의 baseline상 이미 `dismissed=true`인 이전 approve/reject review는 서버 버전에 결합된 authoritative provider 계약으로 bulk-update 대상과 timestamp 동작을 write 전에 확인한 경우에만 해당 native create/submit event에 인과가 증명된 `updated_at` 갱신만 허용한다. 기존 `official`·`dismissed` 값과 ID·body·commit·state·author·stale 및 모든 나머지 필드·record는 그대로 보존하고 영향받은 모든 ID와 신규 review/event cardinality를 readback한다. Timestamp 일치만으로 인과를 추정하지 않으며 계약·인과가 미확인되면 write 전 차단하거나 write 후 `partial`/fail-closed로 중단한다.
|
||||||
|
|||||||
+124
-10
@@ -490,8 +490,10 @@ def _without(value, fields):
|
|||||||
return {key: child for key, child in value.items() if key not in fields}
|
return {key: child for key, child in value.items() if key not in fields}
|
||||||
|
|
||||||
|
|
||||||
def _target_snapshot(snapshot, target):
|
def _target_snapshot(snapshot, target, source_reference=False):
|
||||||
fields = ("issue", "issue_comments", "pr", "pr_comments", "reviews", "timeline", "files")
|
fields = ("issue", "issue_comments", "pr", "pr_comments", "reviews", "timeline", "files")
|
||||||
|
if source_reference:
|
||||||
|
fields += ("issue_timeline", "pr_issue")
|
||||||
_obj(snapshot, fields, "publication snapshot")
|
_obj(snapshot, fields, "publication snapshot")
|
||||||
issue, pr = snapshot["issue"], snapshot["pr"]
|
issue, pr = snapshot["issue"], snapshot["pr"]
|
||||||
if type(issue) is not dict or type(pr) is not dict:
|
if type(issue) is not dict or type(pr) is not dict:
|
||||||
@@ -525,12 +527,12 @@ def _target_snapshot(snapshot, target):
|
|||||||
|
|
||||||
def _projection_contract(contract):
|
def _projection_contract(contract):
|
||||||
if contract is None:
|
if contract is None:
|
||||||
return False, False, None, False
|
return False, False, None, False, False
|
||||||
if type(contract) is not dict or type(contract.get("version")) is not dict or contract["version"].get("version") != "1.27.3":
|
if type(contract) is not dict or type(contract.get("version")) is not dict or contract["version"].get("version") != "1.27.3":
|
||||||
_fail("unsupported provider projection contract")
|
_fail("unsupported provider projection contract")
|
||||||
flags = []
|
flags = []
|
||||||
for key in ("supersession_same_author_only", "already_dismissed_review_timestamp_projection",
|
for key in ("supersession_same_author_only", "already_dismissed_review_timestamp_projection",
|
||||||
"requested_reviewers_presentation_order"):
|
"requested_reviewers_presentation_order", "source_issue_cross_reference_projection"):
|
||||||
value = contract.get(key, False)
|
value = contract.get(key, False)
|
||||||
if type(value) is not bool:
|
if type(value) is not bool:
|
||||||
_fail("invalid provider projection contract")
|
_fail("invalid provider projection contract")
|
||||||
@@ -538,7 +540,114 @@ def _projection_contract(contract):
|
|||||||
official = contract.get("expected_official")
|
official = contract.get("expected_official")
|
||||||
if official is not None and type(official) is not bool:
|
if official is not None and type(official) is not bool:
|
||||||
_fail("invalid provider review policy")
|
_fail("invalid provider review policy")
|
||||||
return flags[0], flags[1], official, flags[2]
|
return flags[0], flags[1], official, flags[2], flags[3]
|
||||||
|
|
||||||
|
|
||||||
|
def collect_publication_snapshot(read_record, fetch_page, target, *, provider_contract=None,
|
||||||
|
page_size=50, max_pages=10000):
|
||||||
|
"""Read-only orchestration; callers own authentication and page wire envelopes."""
|
||||||
|
_obj(target, TARGET, 'snapshot target')
|
||||||
|
for key in ('owner', 'repository'):
|
||||||
|
if not IDENT.fullmatch(_str(target[key], key, 1, 255)) or target[key] in ('.', '..'):
|
||||||
|
_fail('invalid snapshot target')
|
||||||
|
for key in ('issue', 'pull'):
|
||||||
|
_positive_id(target[key])
|
||||||
|
if not HEX40.fullmatch(_str(target['head'], 'head', 40, 40)):
|
||||||
|
_fail('invalid snapshot head')
|
||||||
|
source_reference = _projection_contract(provider_contract)[4]
|
||||||
|
prefix = '/repos/' + target['owner'] + '/' + target['repository']
|
||||||
|
issue = '/issues/' + str(target['issue'])
|
||||||
|
pull = '/pulls/' + str(target['pull'])
|
||||||
|
pr_issue = '/issues/' + str(target['pull'])
|
||||||
|
out = {'issue': read_record(prefix + issue), 'pr': read_record(prefix + pull)}
|
||||||
|
if source_reference:
|
||||||
|
out['pr_issue'] = read_record(prefix + pr_issue)
|
||||||
|
endpoints = [('issue_comments', issue + '/comments', 'id'),
|
||||||
|
('pr_comments', pr_issue + '/comments', 'id'),
|
||||||
|
('reviews', pull + '/reviews', 'id'),
|
||||||
|
('timeline', pr_issue + '/timeline', 'id'),
|
||||||
|
('files', pull + '/files', 'filename')]
|
||||||
|
if source_reference:
|
||||||
|
endpoints.append(('issue_timeline', issue + '/timeline', 'id'))
|
||||||
|
for name, endpoint, key in endpoints:
|
||||||
|
out[name] = collect_pages(lambda page, endpoint=endpoint: fetch_page(prefix + endpoint, page),
|
||||||
|
key, page_size, max_pages)
|
||||||
|
_target_snapshot(out, target, source_reference)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _source_issue_reference(before, after, event, target, pr_url, reviewer_id, causal_times):
|
||||||
|
"""Admit only the source reference to this verified PR review event."""
|
||||||
|
issue_url = pr_url.rsplit('/pulls/', 1)[0] + '/issues/' + str(target['issue'])
|
||||||
|
fullname = target['owner'] + '/' + target['repository']
|
||||||
|
for snapshot in (before, after):
|
||||||
|
pr_issue = snapshot['pr_issue']
|
||||||
|
if type(pr_issue) is not dict or type(pr_issue.get('repository')) is not dict:
|
||||||
|
_fail('invalid PR Issue snapshot')
|
||||||
|
_positive_id(pr_issue.get('id'))
|
||||||
|
if (_positive_id(pr_issue.get('number')) != target['pull'] or
|
||||||
|
pr_issue.get('html_url') != pr_url or
|
||||||
|
pr_issue['repository'].get('full_name') != fullname or
|
||||||
|
_user_id(pr_issue) != _user_id(snapshot['pr']) or
|
||||||
|
pr_issue.get('state') != 'open' or
|
||||||
|
type(pr_issue.get('pull_request')) is not dict or
|
||||||
|
pr_issue['pull_request'].get('merged') is not False or
|
||||||
|
pr_issue['pull_request'].get('html_url') != pr_url or
|
||||||
|
snapshot['issue'].get('html_url') != issue_url):
|
||||||
|
_fail('PR Issue target mismatch')
|
||||||
|
_timestamp(pr_issue.get('updated_at'))
|
||||||
|
old_pr_issue, pr_issue = before['pr_issue'], after['pr_issue']
|
||||||
|
if (set(old_pr_issue) != set(pr_issue) or
|
||||||
|
not _same(_without(old_pr_issue, {'updated_at', 'comments'}),
|
||||||
|
_without(pr_issue, {'updated_at', 'comments'})) or
|
||||||
|
pr_issue['updated_at'] not in causal_times):
|
||||||
|
_fail('PR Issue drift')
|
||||||
|
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:
|
||||||
|
_fail('PR Issue count projection mismatch')
|
||||||
|
old_events = _index(before['issue_timeline'])
|
||||||
|
events = _index(after['issue_timeline'])
|
||||||
|
additions = set(events) - set(old_events)
|
||||||
|
if (len(additions) != 1 or set(old_events) - set(events) or
|
||||||
|
[item['id'] for item in after['issue_timeline'][:-1]] != list(old_events)):
|
||||||
|
_fail('source timeline cardinality or order mismatch')
|
||||||
|
# Existing ref_issue is a current Issue serializer, not an event-time snapshot.
|
||||||
|
# Allow that nested projection only when it equals independent /issues/<PR> readbacks.
|
||||||
|
for old, current in zip(before['issue_timeline'], after['issue_timeline'][:-1]):
|
||||||
|
if _same(old, current):
|
||||||
|
continue
|
||||||
|
if (set(old) != set(current) or
|
||||||
|
not _same(_without(old, {'ref_issue'}), _without(current, {'ref_issue'})) or
|
||||||
|
not _same(old.get('ref_issue'), old_pr_issue) or
|
||||||
|
not _same(current.get('ref_issue'), pr_issue)):
|
||||||
|
_fail('previous source timeline drift')
|
||||||
|
reference = after['issue_timeline'][-1]
|
||||||
|
reference_id = _positive_id(reference.get('id'))
|
||||||
|
if (reference_id not in additions or reference.get('type') != 'comment_ref' or
|
||||||
|
_user_id(reference) != reviewer_id or
|
||||||
|
not _same(reference.get('user'), event['user']) or
|
||||||
|
reference.get('html_url') != issue_url + '#issuecomment-' + str(reference_id) or
|
||||||
|
reference.get('issue_url') != issue_url or reference.get('pull_request_url') != '' or
|
||||||
|
reference.get('body') != '' or reference.get('ref_action') != 'none' or
|
||||||
|
type(reference.get('review_id')) is not int or reference['review_id'] != 0 or
|
||||||
|
not _same(reference.get('ref_issue'), pr_issue)):
|
||||||
|
_fail('source reference target mismatch')
|
||||||
|
comment = reference.get('ref_comment')
|
||||||
|
if type(comment) is not dict or _positive_id(comment.get('id')) != event['id']:
|
||||||
|
_fail('source reference review event mismatch')
|
||||||
|
for key in ('user', 'body', 'created_at', 'updated_at', 'pull_request_url'):
|
||||||
|
if key not in comment or not _same(comment[key], event.get(key)):
|
||||||
|
_fail('source reference review event mismatch')
|
||||||
|
if (comment.get('html_url') != pr_url + '#issuecomment-' + str(event['id']) or
|
||||||
|
comment.get('issue_url') != '' or not _same(comment.get('assets'), [])):
|
||||||
|
_fail('source reference review event mismatch')
|
||||||
|
created = _timestamp(reference.get('created_at'))
|
||||||
|
updated = _timestamp(reference.get('updated_at'))
|
||||||
|
from datetime import datetime
|
||||||
|
if (created != updated or after['issue'].get('updated_at') != created or
|
||||||
|
datetime.fromisoformat(created) < datetime.fromisoformat(event['created_at'])):
|
||||||
|
_fail('source reference timestamp mismatch')
|
||||||
|
return [reference_id], {created}
|
||||||
|
|
||||||
|
|
||||||
def verify_publication(before, after, exact_review, result, allowed_paths, reviewer_id, inline_comments, provider_contract=None, *, expected_pr_url):
|
def verify_publication(before, after, exact_review, result, allowed_paths, reviewer_id, inline_comments, provider_contract=None, *, expected_pr_url):
|
||||||
@@ -549,12 +658,12 @@ def verify_publication(before, after, exact_review, result, allowed_paths, revie
|
|||||||
_fail("inline comments are not supported")
|
_fail("inline comments are not supported")
|
||||||
target = result["target"]
|
target = result["target"]
|
||||||
_str(expected_pr_url, "canonical PR URL", 1, 4000)
|
_str(expected_pr_url, "canonical PR URL", 1, 4000)
|
||||||
url = _target_snapshot(before, target)
|
supersede, retouch, expected_official, reviewer_presentation_order, source_reference = _projection_contract(provider_contract)
|
||||||
if url != expected_pr_url or _target_snapshot(after, target) != expected_pr_url:
|
url = _target_snapshot(before, target, source_reference)
|
||||||
|
if url != expected_pr_url or _target_snapshot(after, target, source_reference) != expected_pr_url:
|
||||||
_fail("snapshot PR URL mismatch")
|
_fail("snapshot PR URL mismatch")
|
||||||
if _user_id(before["pr"]) == reviewer_id:
|
if _user_id(before["pr"]) == reviewer_id:
|
||||||
_fail("reviewer is PR author")
|
_fail("reviewer is PR author")
|
||||||
supersede, retouch, expected_official, reviewer_presentation_order = _projection_contract(provider_contract)
|
|
||||||
old_reviews, reviews = _index(before["reviews"]), _index(after["reviews"])
|
old_reviews, reviews = _index(before["reviews"]), _index(after["reviews"])
|
||||||
if type(exact_review) is not dict:
|
if type(exact_review) is not dict:
|
||||||
_fail("invalid exact review")
|
_fail("invalid exact review")
|
||||||
@@ -592,12 +701,14 @@ def verify_publication(before, after, exact_review, result, allowed_paths, revie
|
|||||||
event_updated = _timestamp(event.get("updated_at"))
|
event_updated = _timestamp(event.get("updated_at"))
|
||||||
# Bind the event by exact record ID/author/body, not coincident wall clocks.
|
# Bind the event by exact record ID/author/body, not coincident wall clocks.
|
||||||
causal_times = {submitted, created, event_updated}
|
causal_times = {submitted, created, event_updated}
|
||||||
|
source_ids, source_times = _source_issue_reference(before, after, event, target, url, reviewer_id, causal_times) if source_reference else ([], set())
|
||||||
for section in ("issue", "pr"):
|
for section in ("issue", "pr"):
|
||||||
b, a = before[section], after[section]
|
b, a = before[section], after[section]
|
||||||
permitted = {"updated_at"} if section == "issue" else {"updated_at", "review_comments", "requested_reviewers"}
|
permitted = {"updated_at"} if section == "issue" else {"updated_at", "review_comments", "requested_reviewers"}
|
||||||
if not _same(_without(b, permitted), _without(a, permitted)) or set(b) != set(a):
|
if not _same(_without(b, permitted), _without(a, permitted)) or set(b) != set(a):
|
||||||
_fail("source or PR drift")
|
_fail("source or PR drift")
|
||||||
if not _same(b.get("updated_at"), a.get("updated_at")) and a.get("updated_at") not in causal_times:
|
permitted_times = causal_times | source_times if section == "issue" else causal_times
|
||||||
|
if not _same(b.get("updated_at"), a.get("updated_at")) and a.get("updated_at") not in permitted_times:
|
||||||
_fail("timestamp projection mismatch")
|
_fail("timestamp projection mismatch")
|
||||||
bpr, apr = before["pr"], after["pr"]
|
bpr, apr = before["pr"], after["pr"]
|
||||||
old_count, count = bpr.get("review_comments"), apr.get("review_comments")
|
old_count, count = bpr.get("review_comments"), apr.get("review_comments")
|
||||||
@@ -639,8 +750,11 @@ def verify_publication(before, after, exact_review, result, allowed_paths, revie
|
|||||||
if not (superseded or retouched) or current.get("official") is not False or current.get("dismissed") is not True:
|
if not (superseded or retouched) or current.get("official") is not False or current.get("dismissed") is not True:
|
||||||
_fail("previous review projection not authorized")
|
_fail("previous review projection not authorized")
|
||||||
projected.append(old_id)
|
projected.append(old_id)
|
||||||
return {"review_id": rid, "state": expected, "review_records_added": 1,
|
out = {"review_id": rid, "state": expected, "review_records_added": 1,
|
||||||
"timeline_events_added": 1, "projected_review_ids": projected, "read_only": True}
|
"timeline_events_added": 1, "projected_review_ids": projected, "read_only": True}
|
||||||
|
if source_reference:
|
||||||
|
out["source_issue_reference_ids"] = source_ids
|
||||||
|
return out
|
||||||
|
|
||||||
def _classification_bytes(path):
|
def _classification_bytes(path):
|
||||||
"""Bounded regular-file read; caller independently admits source provenance."""
|
"""Bounded regular-file read; caller independently admits source provenance."""
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
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)
|
||||||
|
after['pr_issue'].update(comments=3, 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_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()
|
||||||
Reference in New Issue
Block a user