fix: validate Gitea conversation caches and safe commit messages
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: gitea-issue-rework
|
name: gitea-issue-rework
|
||||||
description: Gitea PR 검토 지적을 계약에 맞춰 재작업하고 검증한다.
|
description: Gitea PR 검토 지적을 계약에 맞춰 재작업하고 검증한다.
|
||||||
version: 0.2.15
|
version: 0.2.16
|
||||||
author: genichin, Hermes Agent
|
author: genichin, Hermes Agent
|
||||||
license: MIT
|
license: MIT
|
||||||
platforms: [linux]
|
platforms: [linux]
|
||||||
@@ -178,7 +178,7 @@ Reviewer가 rework head를 새 snapshot으로 다시 검토해야 하며 이 com
|
|||||||
- `correlation`은 `owner`, `repository`, base-10 Issue 번호, base-10 PR 번호, base-10 review ID, lowercase reviewed head, lowercase final head와 lowercase contract digest를 이 순서로 NUL 하나씩 구분한 UTF-8 bytes의 SHA-256이다. `result-sha256`은 correlation 입력에 넣지 않는다.
|
- `correlation`은 `owner`, `repository`, base-10 Issue 번호, base-10 PR 번호, base-10 review ID, lowercase reviewed head, lowercase final head와 lowercase contract digest를 이 순서로 NUL 하나씩 구분한 UTF-8 bytes의 SHA-256이다. `result-sha256`은 correlation 입력에 넣지 않는다.
|
||||||
- 게시 전 exact PR top-level comment endpoint target과 fresh PR metadata binding을 검증하고 그 scoped collection만 complete pagination한 뒤 같은 correlation을 찾는다. Source Issue comment endpoint, inline-comment·review-reply endpoint와 native review collection은 검색 집합에서 제외하며 endpoint scope와 PR relation을 구분할 수 없으면 fail-closed한다. 0개면 exact body를 한 번 생성하고, 1개이며 author stable account ID·body·marker·digest가 모두 같으면 write 없는 no-op으로 재사용한다. 같은 correlation의 body가 다르거나 2개 이상이면 중복을 만들지 않고 fail-closed한다.
|
- 게시 전 exact PR top-level comment endpoint target과 fresh PR metadata binding을 검증하고 그 scoped collection만 complete pagination한 뒤 같은 correlation을 찾는다. Source Issue comment endpoint, inline-comment·review-reply endpoint와 native review collection은 검색 집합에서 제외하며 endpoint scope와 PR relation을 구분할 수 없으면 fail-closed한다. 0개면 exact body를 한 번 생성하고, 1개이며 author stable account ID·body·marker·digest가 모두 같으면 write 없는 no-op으로 재사용한다. 같은 correlation의 body가 다르거나 2개 이상이면 중복을 만들지 않고 fail-closed한다.
|
||||||
- Comment create timeout·오류 뒤에는 blind retry하지 않는다. Complete comment collection을 한 번 authoritative하게 reconcile해 exact result 하나가 확인될 때만 성공으로 판정한다.
|
- Comment create timeout·오류 뒤에는 blind retry하지 않는다. Complete comment collection을 한 번 authoritative하게 reconcile해 exact result 하나가 확인될 때만 성공으로 판정한다.
|
||||||
- 게시 또는 재사용 후 exact comment ID, author stable account ID, body, created·updated timestamp, marker, digest, verified endpoint target, fresh PR metadata binding과 PR top-level collection cardinality를 readback한다. 신규 게시에서는 scoped PR top-level comment collection에 exact comment 하나만 추가되어야 한다. Provider의 aggregate comment-count field는 fresh top-level·review·inline comment cardinality로 설명 가능해야 하며 설명할 수 없으면 fail-closed한다. 신규 게시에서 PR·underlying PR-Issue의 `updated_at`은 불변이거나 exact 신규 comment 생성 timestamp와 같을 때만 허용한다. 이는 다른 native metadata가 불변인 상태의 state-delta/exact-timestamp admission이며, GET readback만으로 독립적인 causal event attestation을 제공하지 않는다. Source Issue `<issue-number>`는 `updated_at`을 포함한 전체 native metadata와 comment collection이 완전히 불변이어야 하며, provider relation을 이유로 timestamp 예외를 허용하지 않는다. Existing exact comment 재사용에서는 PR top-level collection cardinality, PR·underlying issue와 Source Issue field가 모두 불변이어야 한다. 기존 review collection 또는 그 밖의 PR field 변경은 허용하지 않는다.
|
- 게시 또는 재사용 후 exact comment ID, author stable account ID, body, created·updated timestamp, marker, digest, verified endpoint target, fresh PR metadata binding과 PR top-level collection cardinality를 readback한다. 신규 게시에서는 scoped PR top-level comment collection에 exact comment 하나만 추가되어야 한다. Provider의 aggregate comment-count field는 명시된 version-specific 계약으로 설명 가능해야 하며 설명할 수 없으면 fail-closed한다. `gitea-conversation-v1` cache opt-in은 [cached conversation admission](references/readback.md#cached-conversation-admission)에 따라 snapshot의 bounded stale lag만 허용하고, 신규 게시 후에는 실제 전체 conversation 개수와 exact-match를 요구한다. Cached baseline에 무조건 `+1`을 적용하지 않으며 reuse의 counter·metadata 불변 조건은 유지한다. 신규 게시에서 PR·underlying PR-Issue의 `updated_at`은 불변이거나 exact 신규 comment 생성 timestamp와 같을 때만 허용한다. 이는 다른 native metadata가 불변인 상태의 state-delta/exact-timestamp admission이며, GET readback만으로 독립적인 causal event attestation을 제공하지 않는다. Source Issue `<issue-number>`는 `updated_at`을 포함한 전체 native metadata와 comment collection이 완전히 불변이어야 하며, provider relation을 이유로 timestamp 예외를 허용하지 않는다. Existing exact comment 재사용에서는 PR top-level collection cardinality, PR·underlying issue와 Source Issue field가 모두 불변이어야 한다. 기존 review collection 또는 그 밖의 PR field 변경은 허용하지 않는다.
|
||||||
|
|
||||||
완료 조건: exact final head와 finding matrix에 결합된 canonical comment가 impl role author로 정확히 하나 존재하고 authoritative readback을 통과한다.
|
완료 조건: exact final head와 finding matrix에 결합된 canonical comment가 impl role author로 정확히 하나 존재하고 authoritative readback을 통과한다.
|
||||||
|
|
||||||
@@ -277,7 +277,7 @@ pytest를 이미 사용하는 프로젝트의 evidence command에만 적용한
|
|||||||
3. Worktree의 staged·unstaged·untracked state와 미완료 Git operation을 확인한다.
|
3. Worktree의 staged·unstaged·untracked state와 미완료 Git operation을 확인한다.
|
||||||
4. Contract Preservation Inventory가 필요한 경우 final tree에 다시 대응해 baseline 항목 수와 실제 상태를 검증하고 `missing=0`, `ambiguous=0`이 아니면 stage하거나 commit하지 않는다.
|
4. Contract Preservation Inventory가 필요한 경우 final tree에 다시 대응해 baseline 항목 수와 실제 상태를 검증하고 `missing=0`, `ambiguous=0`이 아니면 stage하거나 commit하지 않는다.
|
||||||
5. Exact allowlist만 stage하고 staged diff를 다시 읽는다.
|
5. Exact allowlist만 stage하고 staged diff를 다시 읽는다.
|
||||||
6. Repository convention에 맞는 새 non-amended rework commit을 생성한다. Review별 traceability가 필요하면 commit message나 검증 기록에 PR과 review ID를 포함하되 review body를 그대로 복제하지 않는다.
|
6. Commit 전에 subject와 body를 공통 [commit-message reference safety](../../../../operations/repository-cache.md#commit-message-reference-safety) 규칙에 따라 확인한다. Protected Issue metadata가 immutable이어야 하면 hash-number auto-reference와 parser-triggering closing/reopening 문구를 피하고 `Issue 18 / PR 19 / review 76` 또는 evidence로 추적한다. 기본은 새 non-amended commit이며, amend는 명시적 사용자 승인·미게시 증명·tree 동일성 증명과 새 SHA exact verification이 모두 있을 때만 허용된다. 일반 amend 권한으로 해석하지 않는다.
|
||||||
7. Commit 후 clean HEAD에서 최종 formatter, test와 필요한 runtime·browser 검증을 다시 실행한다. 이 뒤 repository file을 바꾸면 영향을 받은 검증을 다시 실행한다.
|
7. Commit 후 clean HEAD에서 최종 formatter, test와 필요한 runtime·browser 검증을 다시 실행한다. 이 뒤 repository file을 바꾸면 영향을 받은 검증을 다시 실행한다.
|
||||||
8. 최종 검증 기록은 exact HEAD 또는 검증한 source tree, command, exit code, pass·skip·fail 수치, 환경, relevant output과 reviewer가 접근 가능한 evidence location을 포함하며, PR body·결과 comment의 각 실행 주장과 수치는 해당 location에 일대일 대응시킨다. Hermes 내부 tool transcript, delegate 자기보고, 삭제 예정 임시 파일, reviewer가 접근할 수 없는 local path와 요약 수치만 있는 기록은 evidence로 인정하지 않고 `evidence_gap` 또는 `partial`로 유지한다. 새 evidence는 checkout 밖 정본 verification root에 보존하여 final HEAD를 기록하며 evidence-only descendant를 만들지 않는다. 과거 tracked evidence의 archived 분석은 fresh 실행·원격 head 검증과 구분한다.
|
8. 최종 검증 기록은 exact HEAD 또는 검증한 source tree, command, exit code, pass·skip·fail 수치, 환경, relevant output과 reviewer가 접근 가능한 evidence location을 포함하며, PR body·결과 comment의 각 실행 주장과 수치는 해당 location에 일대일 대응시킨다. Hermes 내부 tool transcript, delegate 자기보고, 삭제 예정 임시 파일, reviewer가 접근할 수 없는 local path와 요약 수치만 있는 기록은 evidence로 인정하지 않고 `evidence_gap` 또는 `partial`로 유지한다. 새 evidence는 checkout 밖 정본 verification root에 보존하여 final HEAD를 기록하며 evidence-only descendant를 만들지 않는다. 과거 tracked evidence의 archived 분석은 fresh 실행·원격 head 검증과 구분한다.
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ Initial intake validates collected rows, endpoint/pagination provenance, duplica
|
|||||||
|
|
||||||
The receipt adds `counter_diagnostics`, with keys `inline`, `pr` and `pr_issue`. Each entry records the declared `field`/`components`, unmodified `observed` value, collection-derived `expected` value, and `status`: `match`, `mismatch`, or `unverified` (missing, negative or non-integer observed counter, including booleans). A match means only that the explicitly declared formula matches this observation; it does not attest deployed-provider semantics or counter freshness. Diagnostics never auto-select a projection, modify raw counts, or grant edit/publication permission. Missing/malformed PR counters do not bypass malformed native records, failed GETs, incomplete lists or drift checks.
|
The receipt adds `counter_diagnostics`, with keys `inline`, `pr` and `pr_issue`. Each entry records the declared `field`/`components`, unmodified `observed` value, collection-derived `expected` value, and `status`: `match`, `mismatch`, or `unverified` (missing, negative or non-integer observed counter, including booleans). A match means only that the explicitly declared formula matches this observation; it does not attest deployed-provider semantics or counter freshness. Diagnostics never auto-select a projection, modify raw counts, or grant edit/publication permission. Missing/malformed PR counters do not bypass malformed native records, failed GETs, incomplete lists or drift checks.
|
||||||
|
|
||||||
Provider schema/version binding and explicit declarations are still required. Rework/no-op/blocked decisions retain their role/head/contract/state rules regardless of diagnostics. `snapshot`/`verify-live` publication readback continues to require strict provider counter crosschecks and its existing explicitly declared fallback; an initial receipt cannot substitute for that verification.
|
Initial diagnostic intake remains unchanged. Publication requires strict counter validation and explicit version-specific cache opt-in only: optional aggregate semantics are supported for `cache_mode: "gitea-conversation-v1"`, version `1.27.3`, with `comments` components exactly `["top_level", "reviews"]`; omission of the mode retains strict legacy behavior. No numeric fitting or automatic profile/provider adjustment. Rework/no-op/blocked decisions retain their role/head/contract/state rules regardless of diagnostics. `snapshot`/`verify-live` publication readback continues to require strict provider counter crosschecks and its existing explicitly declared fallback; an initial receipt cannot substitute for that verification.
|
||||||
|
|
||||||
## Derived decisions
|
## Derived decisions
|
||||||
|
|
||||||
|
|||||||
@@ -36,28 +36,52 @@ pre-write delegation admission.
|
|||||||
Schema: `gitea-agent.rework-provider/v1`. The contract explicitly declares the
|
Schema: `gitea-agent.rework-provider/v1`. The contract explicitly declares the
|
||||||
aggregate semantics for PR and underlying PR-Issue `comments` fields. Each
|
aggregate semantics for PR and underlying PR-Issue `comments` fields. Each
|
||||||
`components` array contains unique values from `top_level`, `reviews`, `inline`
|
`components` array contains unique values from `top_level`, `reviews`, `inline`
|
||||||
and must include `top_level`. Choose the reviewed deployment semantics, not the
|
and must include `top_level`. An optional `cache_mode: "gitea-conversation-v1"`
|
||||||
|
on each aggregate scope is accepted only for provider version `1.27.3`, field
|
||||||
|
`comments`, and components consisting of exactly `top_level` and `reviews`.
|
||||||
|
Omission retains strict legacy behavior. Choose the reviewed deployment semantics, not the
|
||||||
combination that happens to fit a current response. There is no guessed version
|
combination that happens to fit a current response. There is no guessed version
|
||||||
or automatic capability downgrade.
|
or automatic capability downgrade.
|
||||||
|
|
||||||
Shape example only, **not a claim about this deployment**:
|
Shape example only: upstream v1.27.3 source supports this shape; it does not attest that a custom build matches upstream:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"schema": "gitea-agent.rework-provider/v1",
|
"schema": "gitea-agent.rework-provider/v1",
|
||||||
"version": "<independently-observed-version>",
|
"version": "1.27.3",
|
||||||
"aggregate": {
|
"aggregate": {
|
||||||
"pr": {"field": "comments", "components": ["top_level"]},
|
"pr": {"field": "comments", "components": ["top_level", "reviews"], "cache_mode": "gitea-conversation-v1"},
|
||||||
"pr_issue": {"field": "comments", "components": ["top_level"]}
|
"pr_issue": {"field": "comments", "components": ["top_level", "reviews"], "cache_mode": "gitea-conversation-v1"}
|
||||||
},
|
},
|
||||||
"inline": {
|
"inline": {
|
||||||
"mode": "per_review",
|
"mode": "per_review",
|
||||||
"field": "review_comments",
|
"field": "review_comments",
|
||||||
"components": ["inline"]
|
"components": ["reviews"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Cached conversation admission
|
||||||
|
|
||||||
|
For the explicit cache mode, snapshot counters must be present non-boolean,
|
||||||
|
nonnegative integers no greater than the complete collection-derived conversation
|
||||||
|
count. This admits bounded stale lag, not arbitrary mismatch; counters ahead of
|
||||||
|
the collected count, missing values and malformed scalars remain BLOCK.
|
||||||
|
Upstream `v1.27.3` `services/convert/issue.go` reads stored `Issue.NumComments`.
|
||||||
|
`models/issues/comment.go` counts plain comments and review-summary comments;
|
||||||
|
review creation bypasses the recomputation that plain-comment creation performs.
|
||||||
|
This source pin explains the opt-in policy, not a deployed custom-build attestation.
|
||||||
|
|
||||||
|
For `create`, fresh readback must prove exactly one canonical top-level addition
|
||||||
|
and the recomputed aggregate must equal the complete post-write conversation
|
||||||
|
count. A stale baseline counter `0` with one review can become `2` after the new
|
||||||
|
plain comment; `before counter + 1` is not the cache-mode rule. All old rows,
|
||||||
|
reviews, inline evidence, Source Issue metadata/comments, identity, body, head
|
||||||
|
and unrelated native fields retain their existing protections. For `reuse`, no
|
||||||
|
addition or counter/metadata refresh is admitted: even a legitimate-looking
|
||||||
|
cache change without the authorized create action is BLOCK. Strict contracts
|
||||||
|
without cache opt-in retain exact snapshot projections and the original delta.
|
||||||
|
|
||||||
For `inline.components`, the explicit projection modes are `["inline"]`,
|
For `inline.components`, the explicit projection modes are `["inline"]`,
|
||||||
`["reviews"]`, and `["reviews", "inline"]`. The review-only mode projects
|
`["reviews"]`, and `["reviews", "inline"]`. The review-only mode projects
|
||||||
only the number of reviews into `review_comments`; it does not add per-review
|
only the number of reviews into `review_comments`; it does not add per-review
|
||||||
|
|||||||
@@ -138,11 +138,16 @@ class ReworkReadbackAdapter:
|
|||||||
for scope in ('pr','pr_issue'):
|
for scope in ('pr','pr_issue'):
|
||||||
spec = object_value(aggregate[scope])
|
spec = object_value(aggregate[scope])
|
||||||
parts = spec.get('components')
|
parts = spec.get('components')
|
||||||
if (set(spec) != {'field','components'} or spec.get('field') != 'comments'
|
mode = spec.get('cache_mode')
|
||||||
|
allowed_keys = {'field','components'} if mode is None and 'cache_mode' not in spec else {'field','components','cache_mode'}
|
||||||
|
if (set(spec) != allowed_keys or spec.get('field') != 'comments'
|
||||||
or type(parts) is not list or not parts
|
or type(parts) is not list or not parts
|
||||||
or any(type(part) is not str or part not in ('top_level','reviews','inline') for part in parts)
|
or any(type(part) is not str or part not in ('top_level','reviews','inline') for part in parts)
|
||||||
or len(parts) != len(set(parts)) or 'top_level' not in parts):
|
or len(parts) != len(set(parts)) or 'top_level' not in parts):
|
||||||
raise ReadbackError('invalid aggregate count declaration')
|
raise ReadbackError('invalid aggregate count declaration')
|
||||||
|
if 'cache_mode' in spec and (mode != 'gitea-conversation-v1' or contract['version'] != '1.27.3'
|
||||||
|
or set(parts) != {'top_level','reviews'}):
|
||||||
|
raise ReadbackError('invalid aggregate cache declaration')
|
||||||
inline = object_value(contract.get('inline'))
|
inline = object_value(contract.get('inline'))
|
||||||
if (set(inline) != {'mode','field','components'}
|
if (set(inline) != {'mode','field','components'}
|
||||||
or inline.get('mode') not in ('per_review','per_review_or_pr_count')
|
or inline.get('mode') not in ('per_review','per_review_or_pr_count')
|
||||||
@@ -392,7 +397,12 @@ class ReworkReadbackAdapter:
|
|||||||
raise ReadbackError('inline provider counter crosscheck failed')
|
raise ReadbackError('inline provider counter crosscheck failed')
|
||||||
for scope in ('pr','pr_issue'):
|
for scope in ('pr','pr_issue'):
|
||||||
spec = self.provider['aggregate'][scope]
|
spec = self.provider['aggregate'][scope]
|
||||||
if integer(raw[scope].get(spec['field'])) != sum(counts[name] for name in spec['components']):
|
observed = raw[scope].get(spec['field'])
|
||||||
|
computed = sum(counts[name] for name in spec['components'])
|
||||||
|
if spec.get('cache_mode') == 'gitea-conversation-v1':
|
||||||
|
if integer(observed) > computed:
|
||||||
|
raise ReadbackError('cached provider aggregate count exceeds collection')
|
||||||
|
elif integer(observed) != computed:
|
||||||
raise ReadbackError('provider aggregate count mismatch')
|
raise ReadbackError('provider aggregate count mismatch')
|
||||||
return counts
|
return counts
|
||||||
|
|
||||||
@@ -586,8 +596,17 @@ class ReworkReadbackAdapter:
|
|||||||
raise ReadbackError('source Issue metadata drift')
|
raise ReadbackError('source Issue metadata drift')
|
||||||
else:
|
else:
|
||||||
field = self.provider['aggregate'][name]['field']
|
field = self.provider['aggregate'][name]['field']
|
||||||
if integer(now.pop(field)) != integer(before.pop(field)) + (1 if action == 'create' else 0):
|
spec = self.provider['aggregate'][name]
|
||||||
|
before_value, now_value = integer(before.get(field)), integer(now.get(field))
|
||||||
|
if spec.get('cache_mode') == 'gitea-conversation-v1':
|
||||||
|
desired = (sum(self.counts(a)[part] for part in spec['components'])
|
||||||
|
if action == 'create' else before_value)
|
||||||
|
if now_value != desired:
|
||||||
|
raise ReadbackError('cached PR counter causality mismatch')
|
||||||
|
elif now_value != before_value + (1 if action == 'create' else 0):
|
||||||
raise ReadbackError('PR counter delta causality mismatch')
|
raise ReadbackError('PR counter delta causality mismatch')
|
||||||
|
before.pop(field)
|
||||||
|
now.pop(field)
|
||||||
if name != 'source_issue' and action == 'create' and now['updated_at'] != before['updated_at']:
|
if name != 'source_issue' and action == 'create' and now['updated_at'] != before['updated_at']:
|
||||||
if now['updated_at'] != matched['created_at']:
|
if now['updated_at'] != matched['created_at']:
|
||||||
raise ReadbackError('uncausal Issue or PR timestamp')
|
raise ReadbackError('uncausal Issue or PR timestamp')
|
||||||
|
|||||||
@@ -163,6 +163,12 @@ python3 <harness-root>/harness/lib/managed_lock_broker.py probe \
|
|||||||
7. 일치한 exact SHA를 이번 작업의 pinned source revision으로 기록한다.
|
7. 일치한 exact SHA를 이번 작업의 pinned source revision으로 기록한다.
|
||||||
8. Issue workflow는 같은 handle에 `release_repository`를 request한다. 별도 non-Issue workflow는 자신의 repository lock을 명시된 순서로 해제한다.
|
8. Issue workflow는 같은 handle에 `release_repository`를 request한다. 별도 non-Issue workflow는 자신의 repository lock을 명시된 순서로 해제한다.
|
||||||
|
|
||||||
|
## Commit-message reference safety
|
||||||
|
|
||||||
|
Before every commit, inspect the complete subject and body for Gitea-parsed hash-number references to local or cross-repository Issues/PRs and closing/reopening keywords. When protected Issue metadata must remain immutable, avoid parser-triggering references: trace as `Issue 18 / PR 19 / review 76` (or cite evidence) rather than `#18`. A PR body’s canonical `Closes #…` reference remains canonical; this is not a blanket instruction to remove it. Using a non-default branch does not prevent reference events.
|
||||||
|
|
||||||
|
A message-only amend is allowed only with explicit user authorization, proof the commit is unpublished, and proof the tree is identical; then verify the new SHA exactly. This is not general permission to amend.
|
||||||
|
|
||||||
## Issue worktree 모델
|
## Issue worktree 모델
|
||||||
|
|
||||||
- 한 repository의 한 Issue에는 persistent linked worktree 하나만 허용한다. worktree는 구현 결과가 안전하게 전달되거나 명시적으로 폐기될 때까지 세션 간 보존한다.
|
- 한 repository의 한 Issue에는 persistent linked worktree 하나만 허용한다. worktree는 구현 결과가 안전하게 전달되거나 명시적으로 폐기될 때까지 세션 간 보존한다.
|
||||||
|
|||||||
@@ -185,7 +185,6 @@ class ReworkReadbackTests(unittest.TestCase):
|
|||||||
('pr',['state'],'closed'), ('underlying',['number'],10),
|
('pr',['state'],'closed'), ('underlying',['number'],10),
|
||||||
('underlying',['pull_request'],True), ('reviews',[0,'commit_id'],'c'*40)]
|
('underlying',['pull_request'],True), ('reviews',[0,'commit_id'],'c'*40)]
|
||||||
error = self.load('rework_readback').ReadbackError
|
error = self.load('rework_readback').ReadbackError
|
||||||
# Each load has a distinct exception class; assert ValueError at the public boundary.
|
|
||||||
self.assertTrue(issubclass(error, ValueError))
|
self.assertTrue(issubclass(error, ValueError))
|
||||||
for attribute, keys, value in cases:
|
for attribute, keys, value in cases:
|
||||||
with self.subTest(attribute=attribute, keys=keys):
|
with self.subTest(attribute=attribute, keys=keys):
|
||||||
@@ -213,7 +212,7 @@ class ReworkReadbackTests(unittest.TestCase):
|
|||||||
(first,self.comments[:50],{},200),
|
(first,self.comments[:50],{},200),
|
||||||
(first,[{'id':True}],{'X-Total-Count':'1'},200),
|
(first,[{'id':True}],{'X-Total-Count':'1'},200),
|
||||||
(first,[],{'X-Total-Count':'0','Link':'not a Link'},200),
|
(first,[],{'X-Total-Count':'0','Link':'not a Link'},200),
|
||||||
(first,[],{'X-Total-Count':'0'},True), (first,[],{},403), (first,[],{},302)]
|
(first,[],{},True), (first,[],{},403), (first,[],{},302)]
|
||||||
for path, rows, headers, status in invalid:
|
for path, rows, headers, status in invalid:
|
||||||
with self.subTest(path=path, headers=headers, status=status):
|
with self.subTest(path=path, headers=headers, status=status):
|
||||||
self.overrides = {path:(rows,headers,status)}
|
self.overrides = {path:(rows,headers,status)}
|
||||||
@@ -299,7 +298,6 @@ class ReworkReadbackTests(unittest.TestCase):
|
|||||||
baseline = adapter.snapshot(self.expected)
|
baseline = adapter.snapshot(self.expected)
|
||||||
self.add_result()
|
self.add_result()
|
||||||
after = adapter.snapshot(self.expected)
|
after = adapter.snapshot(self.expected)
|
||||||
# Prove this exact clean fixture is admitted before adversarial mutation.
|
|
||||||
adapter.normalized_inputs(self.expected, baseline, after, 'create')
|
adapter.normalized_inputs(self.expected, baseline, after, 'create')
|
||||||
cases = [(['schema'],'forged'), (['actor_id'],True), (['origin'],'https://foreign.invalid/api/v1'),
|
cases = [(['schema'],'forged'), (['actor_id'],True), (['origin'],'https://foreign.invalid/api/v1'),
|
||||||
(['target','owner'],'other'), (['provider','version'],'different'),
|
(['target','owner'],'other'), (['provider','version'],'different'),
|
||||||
@@ -458,7 +456,6 @@ class ReworkReadbackTests(unittest.TestCase):
|
|||||||
with self.assertRaises(ValueError): self.adapter().snapshot(self.expected)
|
with self.assertRaises(ValueError): self.adapter().snapshot(self.expected)
|
||||||
|
|
||||||
def test_native_Gitea_PR_URL_and_omitted_proven_zero_inline_count(self):
|
def test_native_Gitea_PR_URL_and_omitted_proven_zero_inline_count(self):
|
||||||
# Pinned primary serializers: services/convert/pull.go and modules/structs/pull.go (v1.24.6).
|
|
||||||
self.pr['url'] = self.pr['html_url']
|
self.pr['url'] = self.pr['html_url']
|
||||||
self.contract['inline']['components'] = ['inline']
|
self.contract['inline']['components'] = ['inline']
|
||||||
self.pr.pop('review_comments')
|
self.pr.pop('review_comments')
|
||||||
@@ -543,3 +540,167 @@ class ReworkReadbackTests(unittest.TestCase):
|
|||||||
reused = adapter.normalized_inputs(self.expected, after, adapter.snapshot(self.expected), 'reuse')
|
reused = adapter.normalized_inputs(self.expected, after, adapter.snapshot(self.expected), 'reuse')
|
||||||
self.assertEqual(self.legacy.verify_readbacks(self.expected, reused['pr'], reused['comments'],
|
self.assertEqual(self.legacy.verify_readbacks(self.expected, reused['pr'], reused['comments'],
|
||||||
reused['identity'], action='reuse', baseline_count=reused['baseline_count'])['status'], 'verified')
|
reused['identity'], action='reuse', baseline_count=reused['baseline_count'])['status'], 'verified')
|
||||||
|
|
||||||
|
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')
|
||||||
|
# Capture a genuine pre-create synchronized baseline, not the post-create rows.
|
||||||
|
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(r'```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)
|
||||||
|
|
||||||
|
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 = (DIRECTORY.parent/'SKILL.md').read_text()
|
||||||
|
self.assertIn('## Commit-message reference safety', common)
|
||||||
|
for clause in ('complete subject and body', 'cross-repository',
|
||||||
|
'non-default branch', 'explicit user authorization',
|
||||||
|
'commit is unpublished', 'tree is identical',
|
||||||
|
'PR body', 'not general permission to amend'):
|
||||||
|
self.assertIn(clause, common)
|
||||||
|
self.assertIn('repository-cache.md#commit-message-reference-safety', skill)
|
||||||
|
|||||||
Reference in New Issue
Block a user