Compare commits
5
Commits
dbd381a41b
...
4e9c46bba2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e9c46bba2 | ||
|
|
a74fb747a5 | ||
|
|
a7a13aa4d5 | ||
|
|
31ac634585 | ||
|
|
e1be0dd526 |
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: gitea-issue-rework
|
||||
description: Gitea PR 검토 지적을 계약에 맞춰 재작업하고 검증한다.
|
||||
version: 0.2.15
|
||||
version: 0.2.17
|
||||
author: genichin, Hermes Agent
|
||||
license: MIT
|
||||
platforms: [linux]
|
||||
@@ -79,7 +79,7 @@ Repository file을 수정하기 전에 다음을 모두 검증한다.
|
||||
- Changed-file, review, review-comment와 commit pagination이 완전하고 diff가 truncated·ambiguous하지 않다.
|
||||
- Canonical Issue worktree와 PR head branch를 현재 실행이 안전하게 재사용할 수 있다.
|
||||
|
||||
Canonical input-gate의 `decision.rework_review_ids`는 현재 head·계약에 결합된 role별 최신 `REQUEST_CHANGES`만 포함한다. `rework`에서도 finding의 계약 근거·scope·독립 승인과 lease/worktree 조건을 별도로 확인한 뒤에만 수정한다. `COMMENT`는 자동 actionable 판정을 하지 않고 `blocked`로 남겨 main의 수동 분류 또는 별도 승인 절차로 넘긴다. 특히 `blocked_spec`, 계약 모순, 선택되지 않은 설계 선택 또는 source reality와 계약 충돌은 구현 재작업으로 해결하지 않으며 Discovery 재검토가 필요하다고 보고한다. `blocked` receipt로 source를 수정하거나 no-op을 주장하지 않는다. Current head·계약의 spec와 code-review role별 최신 유효 review가 모두 `APPROVED`인 fresh `no_op` receipt에서만 write 없는 종료를 한다. 다른 role의 승인·old-head/stale/dismissed review·본문의 해결 주장은 이 조건을 대체하지 않는다.
|
||||
Canonical input-gate의 `decision.rework_review_ids`는 현재 head·계약에 결합된 role별 최신 `REQUEST_CHANGES`만 포함한다. Spec 또는 code-review role 중 하나 이상에 유효한 최신 수정 요청이 있고 다른 차단 사유가 없으면 `rework`다. 다른 role의 유효 review 부재는 `missing_current_review`로 기록하되 이 수정 요청의 재작업 착수를 차단하지 않는다. 수정 요청 없이 review가 부족하면 `blocked`이며 no-op으로 종료하지 않는다. `rework`에서도 finding의 계약 근거·scope·독립 승인과 lease/worktree 조건을 별도로 확인한 뒤에만 수정한다. `COMMENT`는 자동 actionable 판정을 하지 않고 `blocked`로 남겨 main의 수동 분류 또는 별도 승인 절차로 넘긴다. 특히 `blocked_spec`, 계약 모순, 선택되지 않은 설계 선택 또는 source reality와 계약 충돌은 구현 재작업으로 해결하지 않으며 Discovery 재검토가 필요하다고 보고한다. `blocked` receipt로 source를 수정하거나 no-op을 주장하지 않는다. Current head·계약의 spec와 code-review role별 최신 유효 review가 모두 `APPROVED`인 fresh `no_op` receipt에서만 write 없는 종료를 한다. 다른 role의 승인·old-head/stale/dismissed review·본문의 해결 주장은 이 조건을 대체하지 않는다.
|
||||
|
||||
## Snapshot과 finding matrix
|
||||
|
||||
@@ -141,9 +141,9 @@ Canonical body와 marker는 `scripts/rework_comment.py render`로만 생성하
|
||||
```markdown
|
||||
## 구현 재작업 결과
|
||||
|
||||
- Source Issue: #<issue-number>
|
||||
- Pull Request: #<pull-number>
|
||||
- Source Review: #<review-id>
|
||||
- Source Issue: <issue-number>
|
||||
- Pull Request: <pull-number>
|
||||
- Source Review: <review-id>
|
||||
- Reviewed head: `<reviewed-head-sha>`
|
||||
- Rework head: `<final-head-sha>`
|
||||
- Contract SHA-256: `<contract-sha256>`
|
||||
@@ -174,11 +174,12 @@ Reviewer가 rework head를 새 snapshot으로 다시 검토해야 하며 이 com
|
||||
- `rework_delivered`는 모든 `valid_fix`와 `evidence_gap`이 `resolved`이고 final branch delivery와 검증이 완료된 경우에만 사용한다. 그 외 전달 가능한 결과는 `partial`이며 unresolved 이유를 숨기지 않는다.
|
||||
- Finding은 matrix 순서로 정확히 한 번 렌더링한다. 이미 satisfied였고 재작업하지 않은 criterion을 반복해 comment를 부풀리지 않는다.
|
||||
- Path, SHA, enum, command와 수치는 검증된 값을 원문 그대로 사용한다. Human-readable text에는 credential, token, 실제 secret, mention, 외부 지시, HTML 또는 실행 가능한 payload를 포함하지 않는다.
|
||||
- 모든 human-readable comment line에서 local 또는 cross-repository hash-number 자동참조 문법을 쓰지 않는다. Escape한 표기와 Markdown/code 안의 표기도 금지한다. Source Issue, Pull Request, Source Review 예시는 위처럼 plain decimal number로 쓴다. Hidden canonical marker의 identifier와 correlation 입력은 이 표시 규칙 때문에 바꾸지 않는다. Marker를 제외한 exact UTF-8/LF body에 대해 `result-sha256`을 다시 계산한다. 기존 PR body의 의도된 `Closes #<issue-number>`는 그대로 보존하며 변경하지 않는다.
|
||||
- Marker-free body는 LF로 정확히 하나 끝나야 한다. 게시 body는 marker-free bytes, marker line과 최종 LF 하나를 순서대로 사용하며 trailing whitespace를 금지한다.
|
||||
- `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한다.
|
||||
- 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을 통과한다.
|
||||
|
||||
@@ -277,7 +278,7 @@ pytest를 이미 사용하는 프로젝트의 evidence command에만 적용한
|
||||
3. Worktree의 staged·unstaged·untracked state와 미완료 Git operation을 확인한다.
|
||||
4. Contract Preservation Inventory가 필요한 경우 final tree에 다시 대응해 baseline 항목 수와 실제 상태를 검증하고 `missing=0`, `ambiguous=0`이 아니면 stage하거나 commit하지 않는다.
|
||||
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을 바꾸면 영향을 받은 검증을 다시 실행한다.
|
||||
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 검증과 구분한다.
|
||||
|
||||
|
||||
@@ -26,7 +26,15 @@ The target file is exactly schema `gitea-agent.rework-input/v1`:
|
||||
|
||||
Use the independently verified current PR head and SHA-256 of the exact fresh Source Issue UTF-8 body. Names/positive integer IDs/digests/field sets are strict; Issue and PR differ. No selected review ID, final rework head, rendered comment, or `expected.json` is required. The schema and explicit provider contract are validated before credentials or network access.
|
||||
|
||||
Two authenticated GET-only captures reuse identity-first native metadata, repository/PR/Issue/author binding, complete bounded pagination, Link provenance, per-review inline/count capability fallback and drift checks. The initial phase also requires the repository's native default branch to match PR base ref, the exact discovery-draft/v2 first line, and exact Source Issue digest. Discovery structural approval, unresolved-question checks and edit authorization are independent upstream requirements, not claims made by this gate.
|
||||
Two authenticated GET-only captures reuse identity-first native metadata, repository/PR/Issue/author binding, complete bounded pagination, Link provenance, per-review inline record counts and drift checks. Initial intake requires every review's real inline endpoint; 404/405 counter-only fallback is not allowed, even with a `per_review_or_pr_count` provider declaration. The initial phase also requires the repository's native default branch to match PR base ref, the exact discovery-draft/v2 first line, and exact Source Issue digest. Discovery structural approval, unresolved-question checks and edit authorization are independent upstream requirements, not claims made by this gate.
|
||||
|
||||
## Initial collection checks versus publication counter checks
|
||||
|
||||
Initial intake validates collected rows, endpoint/pagination provenance, duplicate IDs (including cross-review inline IDs), review-level `comments_count`, record cardinality and the Source Issue comment collection. These remain blocking checks. PR-wide `comments` and `review_comments` projection crosschecks are separate diagnostics, not evidence that a complete collection is missing records and not initial decision blockers. Source Issue `comments` crosschecking is unchanged.
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
@@ -34,8 +42,8 @@ The `gitea-agent.rework-input-gate/v1` receipt retains parsed native records, pr
|
||||
|
||||
Order by UTC instant with exact up-to-nine-digit fractional precision, then numeric review ID for deterministic display ties. ID order is not causal attestation. Conflicting same-instant same-role state/body/marker records block and have no selected review. Markers use the actual canonical code FIRST-line and spec LAST-line formats; spec result digest and correlation match its renderer's separator-LF algorithm. Current authoritative marker/head/contract/state contradictions block. Role IDs must be positive, distinct from impl, and consistent; unrelated roles cannot supply required approvals. Old-head history is recorded but cannot determine current-head action.
|
||||
|
||||
- `rework`: both roles have a current usable selection, and the only remaining decision reason is latest role-specific `REQUEST_CHANGES`. `decision.rework_review_ids` contains those request IDs only, never spec/code approvals. Validate their findings and authorization independently before source edits.
|
||||
- `rework`: at least one role has a current usable latest `REQUEST_CHANGES`, and there are no blocking reasons other than review absence. A missing counterpart is retained as `missing_current_review` information and does not block acting on the valid request. `decision.rework_review_ids` contains only selected current request IDs, never absent roles, excluded history or spec/code approvals. Validate their findings and authorization independently before source edits.
|
||||
- `no_op`: both spec and code-review role-specific latest current-head, nonstale, nondismissed reviews are `APPROVED` for the fresh contract. A spec approval cannot supersede a code request. Earlier same-role requests may be superseded by a later valid same-role approval, never by another role.
|
||||
- `blocked`: missing current roles, pending authoritative reviews, malformed/ambiguous bindings, same-instant conflicts, current manual `COMMENT`, or `blocked_spec` prevent automatic action. `blocked_spec` is explicit and routes to Discovery investigation, not impl repair. No keyword heuristic or marker alone proves actionable finding semantics.
|
||||
- `blocked`: missing current roles without a usable latest request, pending authoritative reviews, malformed/ambiguous bindings, same-instant conflicts, current manual `COMMENT`, or `blocked_spec` prevent automatic action. Actual blocking reasons take precedence even when a valid request exists. `blocked_spec` is explicit and routes to Discovery investigation, not impl repair. No keyword heuristic or marker alone proves actionable finding semantics.
|
||||
|
||||
This gate's no-op means skip implementation rework, not native review publisher idempotent reuse (which can reuse an existing REQUEST_CHANGES review). Publication, journals and their candidate-selection rules remain unchanged. A receipt is neither publication success nor edit permission, lease/worktree admission, full Discovery approval, independent reviewer-ID attestation, or causal event proof. Do not reuse an old receipt as authority; recheck provider/head/contract and independent upstream gates before an action.
|
||||
|
||||
@@ -36,30 +36,70 @@ pre-write delegation admission.
|
||||
Schema: `gitea-agent.rework-provider/v1`. The contract explicitly declares the
|
||||
aggregate semantics for PR and underlying PR-Issue `comments` fields. Each
|
||||
`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
|
||||
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
|
||||
{
|
||||
"schema": "gitea-agent.rework-provider/v1",
|
||||
"version": "<independently-observed-version>",
|
||||
"version": "1.27.3",
|
||||
"aggregate": {
|
||||
"pr": {"field": "comments", "components": ["top_level"]},
|
||||
"pr_issue": {"field": "comments", "components": ["top_level"]}
|
||||
"pr": {"field": "comments", "components": ["top_level", "reviews"], "cache_mode": "gitea-conversation-v1"},
|
||||
"pr_issue": {"field": "comments", "components": ["top_level", "reviews"], "cache_mode": "gitea-conversation-v1"}
|
||||
},
|
||||
"inline": {
|
||||
"mode": "per_review",
|
||||
"field": "review_comments",
|
||||
"components": ["inline"]
|
||||
"components": ["reviews"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`per_review` requires every exact-review comment endpoint. Only the explicit
|
||||
`per_review_or_pr_count` capability permits endpoint-unavailable 404/405 fallback.
|
||||
### 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"]`,
|
||||
`["reviews"]`, and `["reviews", "inline"]`. The review-only mode projects
|
||||
only the number of reviews into `review_comments`; it does not add per-review
|
||||
inline comments. The inline-only mode projects collected per-review inline
|
||||
comments; the combined mode projects both. These are caller-supplied policies:
|
||||
select one only from independently verified deployed-provider semantics, never
|
||||
automatically detect a mode or choose one to fit observed counters. Both
|
||||
`per_review` and `per_review_or_pr_count` retain the same explicit fallback
|
||||
policy; per-review counters, complete collection pagination, duplicate IDs and
|
||||
record cardinality remain checked.
|
||||
|
||||
`per_review` requires every exact-review comment endpoint. Only publication
|
||||
`snapshot`/`verify-live` with an explicit `per_review_or_pr_count` capability
|
||||
permits endpoint-unavailable 404/405 fallback. Initial `input-gate` instead requires
|
||||
real inline records and rejects this fallback; its PR-wide counter projections are
|
||||
recorded as non-blocking `counter_diagnostics`, not used to establish collection
|
||||
completeness. See [input-gate.md](input-gate.md) for the phase boundary. Publication
|
||||
counter crosschecks, Source Issue protection and review decision rules are unchanged.
|
||||
Permission errors, redirects, malformed JSON and conflicting metadata never
|
||||
trigger fallback. Fallback uses a fresh PR count, the declared review/inline
|
||||
components and complete review `comments_count` records; disagreement is BLOCK.
|
||||
@@ -72,7 +112,11 @@ and `html_url` as HTML URLs, and PR-Issue `pull_request.html_url` without a nati
|
||||
`pull_request.url`. Require the actual native relation URL; do not invent a
|
||||
missing field. This source pin is fixture/protocol evidence, not a claim about
|
||||
the deployment version. The caller's independent provider contract still binds
|
||||
that version. In `per_review` mode only, an omitted `review_comments` is accepted
|
||||
that version. In Gitea `v1.27.3`, `services/convert/pull.go` delegates the PR
|
||||
review-comment count to `models/issues/pull.go`'s `GetReviewCommentsCount`,
|
||||
which filters `CommentTypeReview`; this is source evidence for that version's
|
||||
review-only projection, not a blanket claim for all versions or a live-gate
|
||||
success. In `per_review` mode only, an omitted `review_comments` is accepted
|
||||
as zero only when complete per-review records and their counters prove a zero
|
||||
projection. Explicit null, nonzero projection or counts-only fallback with a
|
||||
missing scalar is rejected. Raw pagination Link evidence is preserved and
|
||||
@@ -136,6 +180,88 @@ JSON/true flags or archived artifacts alone are not fresh provider readback.
|
||||
- The adapter computes normalized inputs and passes them to the same verifier used
|
||||
by `rework_comment.py verify`, rather than accepting caller success flags.
|
||||
|
||||
## Approved bounded correction of an existing publication
|
||||
|
||||
This is a narrow, separately approved exception to normal rework publication—not
|
||||
a new create/reuse policy and not an implementation/review eligibility decision.
|
||||
It applies only when explicit correction approval identifies the same exact,
|
||||
verified impl-authored top-level PR comment ID, and the active Issue lease, exact
|
||||
PR endpoint/metadata binding, complete pagination, unique correlation and absence
|
||||
of duplicate correlation rows are all proven. Approval to correct does not make a
|
||||
stale review admissible, satisfy the normal rework input gate, authorize product
|
||||
edits, or claim normal workflow completion.
|
||||
|
||||
Before any external write, preserve the original baseline bytes and digest,
|
||||
failed verification output/receipt, and source timeline event as immutable
|
||||
historical evidence. Never replace, hide, repair in place, recapture over, or
|
||||
reinterpret the original baseline or failed verify. A fresh readback snapshot is
|
||||
a separate artifact explicitly labeled as a correction snapshot and bound to the
|
||||
historical-baseline digest and the independently recorded correction authority.
|
||||
It detects present state; it does not erase source drift, retroactively validate
|
||||
the old workflow, or yield its original verified handoff.
|
||||
|
||||
The only candidate correction is a pure canonical `prepare-correction` plan.
|
||||
The executable command prepares private JSON only; it does not PATCH and it
|
||||
cannot emit a verified handoff/correction receipt:
|
||||
|
||||
```sh
|
||||
python3 -B <HARNESS_ROOT>/harness/agents/impl/skills/gitea-issue-rework/scripts/rework_comment.py prepare-correction \
|
||||
--previous-expected <ARCHIVED_FAILED_EXPECTED> \
|
||||
--expected <NEW_RENDERED_EXPECTED> \
|
||||
--comment-id <EXACT_EXISTING_COMMENT_ID> --actor-id <STABLE_IMPL_ID> \
|
||||
--output <PRIVATE_CORRECTION_PLAN>
|
||||
```
|
||||
|
||||
The output is `gitea-rework-correction-plan/v1`, with `approval_required=true`,
|
||||
`prior_handoff_verified=false` and no native write/readback proof. The old and
|
||||
new inputs and any existing plan are never overwritten.
|
||||
|
||||
The plan proves the legacy body has the exact three label-line format for Source Issue,
|
||||
Pull Request and Source Review, changes those human-readable values to plain
|
||||
decimal numbers, preserves all other human-readable semantics and every hidden
|
||||
marker identifier/correlation input unchanged, then recomputes the marker-free
|
||||
body `result-sha256` and canonical marker. It must prove there are no changed
|
||||
findings, result/status, reviewed/final head, contract digest, PR body semantics,
|
||||
marker identity or other semantic differences. The plan helper is a pure
|
||||
preparation/validation function: it performs no native write and provides no
|
||||
approval assurance. Any non-exact legacy format or additional body/marker change
|
||||
is unsupported and must stop before mutation. Existing same-correlation,
|
||||
different-body cases remain fail-closed under ordinary create/reuse; this
|
||||
correction exception is not permission to create another comment or broaden
|
||||
reuse.
|
||||
|
||||
Only the main impl workflow, with separate explicit correction approval and the
|
||||
active Issue lease, may execute the single PATCH. Immediately before that PATCH,
|
||||
perform fresh unchanged checks against the correction snapshot: exact target,
|
||||
comment ID, unique correlation/no duplicates, author identity, current body,
|
||||
Source Issue, PR, underlying PR-Issue, reviews, inline evidence, complete
|
||||
collections and provider contract must still match, with the expected API status
|
||||
and version token derived from the matching source response. If any material
|
||||
state drifted, the API lacks a trustworthy status/version precondition, or
|
||||
one-time conditional PATCH semantics cannot be established, stop as partial;
|
||||
do not substitute POST, retry, delete, or restore source state.
|
||||
|
||||
After PATCH, read back the exact same comment ID and verify actor stable account
|
||||
ID, immutable `created_at`, URL, full scoped collection count, exact corrected
|
||||
body, recomputed result digest and unchanged marker identity/correlation. Prove
|
||||
all old rows, reviews, inline evidence, Source Issue fields and entire metadata
|
||||
(including `updated_at`), timeline, and cached counter are unchanged. Only this
|
||||
comment's body/result hash/marker and a provider-verified comment `updated_at` or
|
||||
revision may change, and only with the version/precondition proof. A PR or
|
||||
underlying PR-Issue `updated_at` may change only when the exact timestamp is
|
||||
explained by the provider's verified correction semantics. Any unexplained delta,
|
||||
missing field, incomplete pagination or unsupported semantic is partial and
|
||||
stops further action. Never delete comments, duplicate-POST, restore or rewrite
|
||||
the Source Issue, perform a native review, or clean up source timeline history.
|
||||
|
||||
Archive the original failed baseline and failed verification as still incomplete.
|
||||
Issue a distinct correction receipt, explicitly linked to the historical baseline
|
||||
digest, approval authority, fresh correction snapshot, same comment ID and
|
||||
verified post-PATCH readback. This receipt is not the original `gitea-rework-handoff`
|
||||
receipt and cannot turn the original workflow into a verified completion. The
|
||||
source-repair record reports only the bounded correction and its limitations;
|
||||
no product edits or claim that an old/stale review is admissible.
|
||||
|
||||
## Limits and verification
|
||||
|
||||
The CLI enforces verified HTTPS, no redirects, GET-only requests, a one-megabyte
|
||||
|
||||
@@ -45,7 +45,7 @@ def safe_human(s,name):
|
||||
if not isinstance(s,str) or not s or len(s)>10000 or "\r" in s or "\n" in s or s.rstrip()!=s: raise Bad(f"invalid {name}")
|
||||
# Keep the renderer deliberately conservative: no markup, mentions, URLs,
|
||||
# credentials, shell-like payloads, or HTML in untrusted fields.
|
||||
if re.search(r"<[^>]*>|(?:https?://|www\.)|(?:^|\s)@[A-Za-z0-9_.-]+|(?:password|passwd|secret|token|api[_ -]?key|authorization|bearer|credential)\s*[:=]",s,re.I): raise Bad(f"unsafe {name}")
|
||||
if re.search(r"<[^>]*>|(?:https?://|www\.)|(?:^|\s)@[A-Za-z0-9_.-]+|#[0-9]+|(?:password|passwd|secret|token|api[_ -]?key|authorization|bearer|credential)\s*[:=]",s,re.I): raise Bad(f"unsafe {name}")
|
||||
return s
|
||||
|
||||
def canon(d):
|
||||
@@ -73,7 +73,7 @@ def canon(d):
|
||||
return d, vals, out, extras, cs, ar
|
||||
|
||||
def render_body(d,vals,findings,extras,cs,ar):
|
||||
lines=["## 구현 재작업 결과","",f"- Source Issue: #{vals['issue']}",f"- Pull Request: #{vals['pr']}",f"- Source Review: #{vals['review']}",f"- Reviewed head: `{d['reviewed_head']}`",f"- Rework head: `{d['final_head']}`",f"- Contract SHA-256: `{d['contract_sha256']}`",f"- Result: `{d['result']}`","","### 지적 처리",""]
|
||||
lines=["## 구현 재작업 결과","",f"- Source Issue: {vals['issue']}",f"- Pull Request: {vals['pr']}",f"- Source Review: {vals['review']}",f"- Reviewed head: `{d['reviewed_head']}`",f"- Rework head: `{d['final_head']}`",f"- Contract SHA-256: `{d['contract_sha256']}`",f"- Result: `{d['result']}`","","### 지적 처리",""]
|
||||
for fid,status,cls,ch,ver,reason in findings:
|
||||
lines += [f"- {fid} — `{status}`",f" - 분류: {cls}",f" - 변경: {ch}",f" - 검증: {ver}",f" - 근거: {reason}"]
|
||||
lines += ["","### 최종 검증","",f"- Local checks: {extras['local_checks']}",f"- Runtime/browser: {extras['runtime_browser']}",f"- Commit status: {cs}",f"- Actions runs: {ar}","","### 다음 단계","","Reviewer가 rework head를 새 snapshot으로 다시 검토해야 하며 이 comment는 기존 `REQUEST_CHANGES`를 해제하거나 승인을 의미하지 않는다.",""]
|
||||
@@ -112,6 +112,7 @@ def verify_readbacks(exp, pr, comment_readback, ident, *, action, baseline_count
|
||||
if action not in ('create', 'reuse') or type(baseline_count) is not int or baseline_count < 0:
|
||||
raise Bad('invalid verification action or baseline count')
|
||||
body=exp.get("body"); rh=exp.get("result_sha256"); corr=exp.get("correlation"); head=exp.get("head")
|
||||
if isinstance(body,str) and re.search(r"#[0-9]+",body): raise Bad("unsafe expected identifier reference")
|
||||
marker=exp.get("marker","")
|
||||
marker_free=exp.get("marker_free_body", body[:-len(marker)] if marker and body.endswith(marker) else body)
|
||||
target=exp.get("target")
|
||||
@@ -158,11 +159,87 @@ def do_verify(a):
|
||||
action=a.action, baseline_count=a.baseline_count)
|
||||
atomic(a.output,(json.dumps(receipt,sort_keys=True,separators=(",",":"))+"\n").encode())
|
||||
|
||||
|
||||
def _correction_expected(exp, *, legacy=False):
|
||||
if type(exp) is not dict: raise Bad("expected object")
|
||||
body, marker, free = exp.get("body"), exp.get("marker"), exp.get("marker_free_body")
|
||||
if (type(body) is not str or type(marker) is not str or type(free) is not str
|
||||
or len(body.encode()) > MAX or body != free + marker or "\r" in free
|
||||
or not free.endswith("\n")):
|
||||
raise Bad("expected body binding")
|
||||
target = exp.get("target")
|
||||
if type(target) is not dict: raise Bad("expected target")
|
||||
owner=text(target.get("owner"),"owner",re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$"))
|
||||
repository=text(target.get("repository"),"repository",re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$"))
|
||||
issue=number(target.get("issue"),"issue"); pr=number(target.get("pr"),"pr")
|
||||
review=number(exp.get("review"),"review")
|
||||
reviewed=text(exp.get("reviewed_head"),"reviewed_head",SHA); head=text(exp.get("head"),"head",SHA)
|
||||
contract=text(exp.get("contract_sha256"),"contract_sha256",DIGEST)
|
||||
corr=hashlib.sha256("\0".join([owner,repository,issue,pr,review,reviewed,head,contract]).encode()).hexdigest()
|
||||
rh=hashlib.sha256(free.encode()).hexdigest()
|
||||
expected_marker=f"{MARK}owner={owner} repository={repository} issue={issue} pr={pr} review={review} reviewed-head={reviewed} head={head} contract-sha256={contract} result-sha256={rh} correlation={corr} -->\n"
|
||||
if marker != expected_marker or exp.get("result_sha256") != rh or exp.get("correlation") != corr:
|
||||
raise Bad("expected marker or digest")
|
||||
lines=free.splitlines(keepends=True)
|
||||
labels=[f"- Source Issue: {issue}\n",f"- Pull Request: {pr}\n",f"- Source Review: {review}\n"]
|
||||
wanted=[label.replace(": ",": #",1) for label in labels] if legacy else labels
|
||||
if len(lines)<12 or lines[2:5]!=wanted: raise Bad("canonical identifier lines")
|
||||
normalized=list(lines); normalized[2:5]=labels
|
||||
normal="".join(normalized)
|
||||
if re.search(r"#[0-9]+",normal): raise Bad("unexpected identifier reference")
|
||||
# Parse the existing canonical layout, then round-trip through its renderer;
|
||||
# this is a bounded format migration, not arbitrary text with a valid hash.
|
||||
pieces=normal.split("\n\n### 최종 검증\n\n")
|
||||
if len(pieces)!=2: raise Bad("malformed canonical body")
|
||||
prefix_rows=pieces[0].splitlines()
|
||||
rows=prefix_rows[12:]
|
||||
if not rows or len(rows)%5: raise Bad("malformed finding rows")
|
||||
findings=[]
|
||||
for i in range(0,len(rows),5):
|
||||
match=re.fullmatch(r"- ([A-Za-z0-9][A-Za-z0-9._:-]{0,127}) — `(resolved|unresolved)`",rows[i])
|
||||
field_prefixes=(" - 분류: "," - 변경: "," - 검증: "," - 근거: ")
|
||||
if match is None or any(not rows[i+j+1].startswith(p) for j,p in enumerate(field_prefixes)):
|
||||
raise Bad("malformed finding rows")
|
||||
vals=[rows[i+j+1][len(p):] for j,p in enumerate(field_prefixes)]
|
||||
findings.append(dict(id=match[1],status=match[2],classification=vals[0],changes=vals[1],verification=vals[2],reason=vals[3]))
|
||||
checks=pieces[1].splitlines()
|
||||
prefixes=("- Local checks: ","- Runtime/browser: ","- Commit status: ","- Actions runs: ")
|
||||
if len(checks)<4 or any(not checks[i].startswith(p) for i,p in enumerate(prefixes)):
|
||||
raise Bad("malformed check rows")
|
||||
result=re.fullmatch(r"- Result: `(rework_delivered|partial)`\n",lines[8])
|
||||
if result is None: raise Bad("malformed result line")
|
||||
data=dict(owner=owner,repository=repository,issue=int(issue),pr=int(pr),review=int(review),reviewed_head=reviewed,final_head=head,contract_sha256=contract,result=result[1],findings=findings,
|
||||
local_checks=checks[0][len(prefixes[0]):],runtime_browser=checks[1][len(prefixes[1]):],commit_status=checks[2][len(prefixes[2]):],actions_runs=checks[3][len(prefixes[3]):])
|
||||
d,vals,fs,ex,cs,ar=canon(data)
|
||||
if normal!=render_body(d,vals,fs,ex,cs,ar): raise Bad("malformed canonical body")
|
||||
return dict(owner=owner,repository=repository,issue=issue,pr=pr,review=review,reviewed_head=reviewed,head=head,contract_sha256=contract,correlation=corr,result_sha256=rh,body=body,marker=marker,free=free,normal=normal,lines=lines,found=[2,3,4])
|
||||
|
||||
|
||||
def prepare_correction(previous_expected, new_expected, *, comment_id, actor_id):
|
||||
old=_correction_expected(previous_expected, legacy=True); new=_correction_expected(new_expected)
|
||||
cid=number(comment_id,"comment_id"); aid=number(actor_id,"actor_id")
|
||||
immutable=("owner","repository","issue","pr","review","reviewed_head","head","contract_sha256","correlation")
|
||||
if any(old[k]!=new[k] for k in immutable): raise Bad("correction binding changed")
|
||||
if old["normal"] != new["free"]: raise Bad("correction semantic transform")
|
||||
return {"schema":"gitea-rework-correction-plan/v1","target":{"owner":old["owner"],"repository":old["repository"],"issue":int(old["issue"]),"pr":int(old["pr"])},"comment_id":int(cid),"actor_id":int(aid),"old_body":old["body"],"new_body":new["body"],"old_body_sha256":hashlib.sha256(old["body"].encode()).hexdigest(),"new_body_sha256":hashlib.sha256(new["body"].encode()).hexdigest(),"prior_handoff_verified":False,"immutable_source_required":True,"head":old["head"],"correlation":old["correlation"],"old_expected":{"review":int(old["review"]),"reviewed_head":old["reviewed_head"],"contract_sha256":old["contract_sha256"],"result_sha256":old["result_sha256"]},"new_expected":{"review":int(new["review"]),"reviewed_head":new["reviewed_head"],"contract_sha256":new["contract_sha256"],"result_sha256":new["result_sha256"]},"approval_required":True,"external_write_performed":False,"readback_proof":False}
|
||||
|
||||
|
||||
def do_prepare_correction(a):
|
||||
from rework_readback_cli import read_bound_json, OutputFile
|
||||
inputs=[Path(a.previous_expected),Path(a.expected)]
|
||||
out=Path(a.output)
|
||||
if any(out == p or out.resolve() == p.resolve() for p in inputs): raise Bad("output aliases input")
|
||||
for p in inputs:
|
||||
if not p.is_absolute() or any(part in (".","..") for part in p.parts): raise Bad("canonical input path required")
|
||||
plan=prepare_correction(read_bound_json(a.previous_expected),read_bound_json(a.expected),comment_id=a.comment_id,actor_id=a.actor_id)
|
||||
with OutputFile(a.output, initial_only=True) as output: output.write(plan)
|
||||
|
||||
def main():
|
||||
p=argparse.ArgumentParser(); sp=p.add_subparsers(dest="cmd",required=True)
|
||||
r=sp.add_parser("render"); r.add_argument("--input",required=True); r.add_argument("--comment",required=True); r.add_argument("--expected",required=True)
|
||||
adoption=r.add_mutually_exclusive_group(required=True); adoption.add_argument("--no-delegated-evidence",action="store_true"); adoption.add_argument("--delegation-adoption")
|
||||
v=sp.add_parser("verify"); v.add_argument("--expected",required=True); v.add_argument("--pr-readback",required=True); v.add_argument("--comments-readback",required=True); v.add_argument("--identity-readback",required=True); v.add_argument("--action",choices=("create","reuse"),required=True); v.add_argument("--baseline-count",type=int,required=True); v.add_argument("--output",required=True)
|
||||
c=sp.add_parser("prepare-correction"); c.add_argument("--previous-expected",required=True); c.add_argument("--expected",required=True); c.add_argument("--comment-id",required=True,type=int); c.add_argument("--actor-id",required=True,type=int); c.add_argument("--output",required=True)
|
||||
for command in ('snapshot', 'verify-live'):
|
||||
live = sp.add_parser(command)
|
||||
live.add_argument('--expected', required=True)
|
||||
@@ -185,6 +262,7 @@ def main():
|
||||
try:
|
||||
if args.cmd == 'render': do_render(args)
|
||||
elif args.cmd == 'verify': do_verify(args)
|
||||
elif args.cmd == 'prepare-correction': do_prepare_correction(args)
|
||||
elif args.cmd == 'input-gate':
|
||||
from rework_readback_cli import run_input_gate
|
||||
run_input_gate(args)
|
||||
|
||||
@@ -120,8 +120,8 @@ def _validate_raw(adapter, raw, inp):
|
||||
raise InputGateError('incomplete review comment endpoints')
|
||||
for rid, collected in raw['inline'].items():
|
||||
adapter.validate_collection(collected, prefix+'/pulls/'+str(target['pr'])+'/reviews/'+rid+'/comments',
|
||||
allow_missing=adapter.provider['inline']['mode'] == 'per_review_or_pr_count')
|
||||
adapter.counts(raw)
|
||||
allow_missing=False)
|
||||
adapter.collection_counts(raw)
|
||||
source = raw['source_issue']
|
||||
if source['body'].splitlines()[:1] != ['<!-- gitea-agent:discovery-draft/v2 -->']:
|
||||
raise InputGateError('invalid discovery draft marker')
|
||||
@@ -210,11 +210,17 @@ def derive_decision(raw, inp, impl_actor_id):
|
||||
latest=same[-1]; selected[role]=latest['id']
|
||||
if latest['_verdict']=='request_changes': reasons.append('request_changes')
|
||||
elif latest['_verdict']=='blocked_spec': reasons.append('blocked_spec')
|
||||
if not reasons:
|
||||
status='no_op'
|
||||
elif reasons and set(reasons) == {'request_changes'}:
|
||||
status='rework'
|
||||
else: status='blocked'
|
||||
reason_set = set(reasons)
|
||||
# Missing peer review prevents no-op, not acting on a valid selected request.
|
||||
blocking_reasons = reason_set - {'request_changes', 'missing_current_review'}
|
||||
if blocking_reasons:
|
||||
status = 'blocked'
|
||||
elif 'request_changes' in reason_set:
|
||||
status = 'rework'
|
||||
elif 'missing_current_review' in reason_set:
|
||||
status = 'blocked'
|
||||
else:
|
||||
status = 'no_op'
|
||||
for row in timeline:
|
||||
for key in ('_time','_verdict','_role','_marker_error'): row.pop(key,None)
|
||||
decision={'status':status,'reason_codes':sorted(set(reasons)),'target':copy.deepcopy(inp['target']),'head':inp['head'],
|
||||
@@ -225,11 +231,12 @@ def derive_decision(raw, inp, impl_actor_id):
|
||||
if len(json.dumps(decision,ensure_ascii=False,sort_keys=True).encode()) > 1_000_000: raise InputGateError('decision artifact too large')
|
||||
return decision
|
||||
|
||||
def build_receipt(raw, inp, decision):
|
||||
def build_receipt(raw, inp, decision, counter_diagnostics):
|
||||
receipt = {'schema':RECEIPT_SCHEMA,'status':decision['status'],'reason_codes':decision['reason_codes'],
|
||||
'target':decision['target'],'head':decision['head'],'contract_sha256':decision['contract_sha256'],
|
||||
'actor':decision['actor'],'input_sha256':digest(inp),'raw_input_snapshot':copy.deepcopy(raw),
|
||||
'raw_input_sha256':digest(raw),'fresh_GET_provenance':{'kind':'fresh_authenticated_GET','read_only':True},
|
||||
'counter_diagnostics':copy.deepcopy(counter_diagnostics),
|
||||
'decision':decision}
|
||||
if len(json.dumps(receipt, ensure_ascii=False, sort_keys=True, allow_nan=False).encode('utf-8')) > 1_000_000:
|
||||
raise InputGateError('receipt artifact too large')
|
||||
|
||||
@@ -138,16 +138,21 @@ class ReworkReadbackAdapter:
|
||||
for scope in ('pr','pr_issue'):
|
||||
spec = object_value(aggregate[scope])
|
||||
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 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):
|
||||
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'))
|
||||
if (set(inline) != {'mode','field','components'}
|
||||
or inline.get('mode') not in ('per_review','per_review_or_pr_count')
|
||||
or inline.get('field') != 'review_comments'
|
||||
or inline.get('components') not in (['inline'], ['reviews','inline'])):
|
||||
or inline.get('components') not in (['inline'], ['reviews'], ['reviews','inline'])):
|
||||
raise ReadbackError('invalid inline count declaration')
|
||||
return copy.deepcopy(contract)
|
||||
|
||||
@@ -332,13 +337,15 @@ class ReworkReadbackAdapter:
|
||||
or review['user']['login'] not in ('gitea-review-agent', 'gitea-spec-agent')):
|
||||
raise ReadbackError('source review author or state mismatch')
|
||||
|
||||
def counts(self, raw):
|
||||
def _collection_counts(self, raw, *, allow_counter_fallback=False):
|
||||
inline = sum(integer(review.get('comments_count')) for review in raw['reviews']['rows'])
|
||||
fallback = False
|
||||
inline_ids = set()
|
||||
for review in raw['reviews']['rows']:
|
||||
collected = raw['inline'][str(review['id'])]
|
||||
if collected['pages'][0]['status'] in (404,405):
|
||||
if not allow_counter_fallback:
|
||||
raise ReadbackError('inline collection unavailable for input gate')
|
||||
if self.provider['inline']['mode'] != 'per_review_or_pr_count':
|
||||
raise ReadbackError('inline fallback not explicitly authorized')
|
||||
fallback = True
|
||||
@@ -347,27 +354,59 @@ class ReworkReadbackAdapter:
|
||||
for row in collected['rows']:
|
||||
if row['id'] in inline_ids: raise ReadbackError('duplicate cross-review inline ID')
|
||||
inline_ids.add(row['id'])
|
||||
computed = inline + (len(raw['reviews']['rows']) if 'reviews' in self.provider['inline']['components'] else 0)
|
||||
field = self.provider['inline']['field']
|
||||
if field not in raw['pr']:
|
||||
if (self.provider['inline']['mode'] != 'per_review' or fallback or computed != 0
|
||||
or len(inline_ids) != 0):
|
||||
raise ReadbackError('missing inline provider counter')
|
||||
elif integer(raw['pr'].get(field)) != computed:
|
||||
raise ReadbackError('inline provider counter crosscheck failed')
|
||||
if not fallback and len(inline_ids) != inline:
|
||||
raise ReadbackError('inline record cardinality mismatch')
|
||||
counts = {'top_level':len(raw['comments']['rows']),
|
||||
'reviews':len(raw['reviews']['rows']), 'inline':inline}
|
||||
for scope in ('pr','pr_issue'):
|
||||
spec = self.provider['aggregate'][scope]
|
||||
if integer(raw[scope].get(spec['field'])) != sum(counts[name] for name in spec['components']):
|
||||
raise ReadbackError('provider aggregate count mismatch')
|
||||
if integer(raw['source_issue'].get('comments')) != len(raw['source_comments']['rows']):
|
||||
raise ReadbackError('source collection count mismatch')
|
||||
return counts, fallback
|
||||
|
||||
def collection_counts(self, raw):
|
||||
"""Initial intake requires real inline records, never a counter-only fallback."""
|
||||
return self._collection_counts(raw)[0]
|
||||
|
||||
def counter_diagnostics(self, raw):
|
||||
"""Declared PR projections are diagnostic only for complete initial intake."""
|
||||
counts = self.collection_counts(raw)
|
||||
diagnostics = {}
|
||||
projections = {'inline': ('pr', self.provider['inline'])}
|
||||
projections.update({scope: (scope, self.provider['aggregate'][scope])
|
||||
for scope in ('pr', 'pr_issue')})
|
||||
for name, (scope, spec) in projections.items():
|
||||
observed = raw[scope].get(spec['field'])
|
||||
expected = sum(counts[part] for part in spec['components'])
|
||||
valid = type(observed) is int and observed >= 0
|
||||
diagnostics[name] = {'field':spec['field'], 'components':list(spec['components']),
|
||||
'observed':observed, 'expected':expected,
|
||||
'status':('match' if observed == expected else 'mismatch')
|
||||
if valid else 'unverified'}
|
||||
return diagnostics
|
||||
|
||||
def counts(self, raw):
|
||||
"""Publication/readback retains strict provider counter crosschecks."""
|
||||
counts, fallback = self._collection_counts(raw, allow_counter_fallback=True)
|
||||
parts = self.provider['inline']['components']
|
||||
computed = sum(counts[part] for part in parts)
|
||||
field = self.provider['inline']['field']
|
||||
if field not in raw['pr']:
|
||||
if (self.provider['inline']['mode'] != 'per_review' or fallback or computed != 0
|
||||
or counts['inline'] != 0):
|
||||
raise ReadbackError('missing inline provider counter')
|
||||
elif integer(raw['pr'].get(field)) != computed:
|
||||
raise ReadbackError('inline provider counter crosscheck failed')
|
||||
for scope in ('pr','pr_issue'):
|
||||
spec = self.provider['aggregate'][scope]
|
||||
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')
|
||||
return counts
|
||||
|
||||
def capture_input(self, target, *, require_default_branch=True):
|
||||
def _capture_raw(self, target, *, require_default_branch):
|
||||
identity, _ = self.get('/user')
|
||||
if integer(identity.get('id'), 1) != self.actor_id or identity.get('login') != 'gitea-impl-agent':
|
||||
raise ReadbackError('authenticated identity mismatch')
|
||||
@@ -387,11 +426,16 @@ class ReworkReadbackAdapter:
|
||||
raw['inline'] = {str(review['id']):self.collection(prefix+'/pulls/'+str(target['pr'])+
|
||||
'/reviews/'+str(review['id'])+'/comments', allow_missing=self.provider['inline']['mode'] == 'per_review_or_pr_count') for review in raw['reviews']['rows']}
|
||||
self.bind_input(target, raw, require_default_branch=require_default_branch)
|
||||
self.counts(raw)
|
||||
return raw
|
||||
|
||||
def capture_input(self, target, *, require_default_branch=True):
|
||||
raw = self._capture_raw(target, require_default_branch=require_default_branch)
|
||||
self.collection_counts(raw)
|
||||
return raw
|
||||
|
||||
def capture(self, target):
|
||||
raw = self.capture_input(target, require_default_branch=False)
|
||||
raw = self._capture_raw(target, require_default_branch=False)
|
||||
self.counts(raw)
|
||||
self.bind(target, raw)
|
||||
return raw
|
||||
|
||||
@@ -414,7 +458,7 @@ class ReworkReadbackAdapter:
|
||||
if digest(first) != digest(second):
|
||||
raise ReadbackError('concurrent provider drift')
|
||||
decision = derive_decision(first, value, self.actor_id)
|
||||
return build_receipt(first, value, decision)
|
||||
return build_receipt(first, value, decision, self.counter_diagnostics(first))
|
||||
|
||||
def snapshot(self, expected):
|
||||
target = self.target(expected)
|
||||
@@ -552,8 +596,17 @@ class ReworkReadbackAdapter:
|
||||
raise ReadbackError('source Issue metadata drift')
|
||||
else:
|
||||
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')
|
||||
before.pop(field)
|
||||
now.pop(field)
|
||||
if name != 'source_issue' and action == 'create' and now['updated_at'] != before['updated_at']:
|
||||
if now['updated_at'] != matched['created_at']:
|
||||
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으로 기록한다.
|
||||
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 모델
|
||||
|
||||
- 한 repository의 한 Issue에는 persistent linked worktree 하나만 허용한다. worktree는 구현 결과가 안전하게 전달되거나 명시적으로 폐기될 때까지 세션 간 보존한다.
|
||||
|
||||
@@ -67,4 +67,117 @@ class ReworkCommentTests(unittest.TestCase):
|
||||
self.readback(); identity=self.read("identity.json"); identity["role"]="other"; self.write("identity.json",identity); self.assertNotEqual(self.verify().returncode,0)
|
||||
self.readback(); comments=self.read("comments.json"); comments["provider_counts"]["aggregate_comments"]+=1; self.write("comments.json",comments); self.assertNotEqual(self.verify().returncode,0)
|
||||
|
||||
def test_render_visible_identifier_lines_do_not_emit_issue_references(self):
|
||||
body=self.expected["marker_free_body"]
|
||||
self.assertIn("- Source Issue: 7",body)
|
||||
self.assertIn("- Pull Request: 12",body)
|
||||
self.assertIn("- Source Review: 99",body)
|
||||
self.assertNotIn("- Source Issue: #7",body)
|
||||
self.assertNotIn("- Pull Request: #12",body)
|
||||
self.assertNotIn("- Source Review: #99",body)
|
||||
|
||||
def test_render_rejects_reference_hazards_in_every_safe_human_field(self):
|
||||
hazards=("#123", "otherrepo#123", r"\\#123", "\\#123", "review #99")
|
||||
fields=("changes","verification","reason","local_checks","runtime_browser")
|
||||
# Exercise finding fields and global fields independently; references
|
||||
# must not be emitted even when they resemble another repo or are escaped.
|
||||
for field in fields:
|
||||
for snippet in hazards:
|
||||
with self.subTest(field=field,snippet=snippet):
|
||||
data=json.loads(json.dumps(self.data))
|
||||
if field in ("changes","verification","reason"):
|
||||
data["findings"][0][field]="unsafe payload "+snippet
|
||||
else:
|
||||
data[field]="unsafe payload "+snippet
|
||||
self.write("hazard.json",data)
|
||||
result=self.invoke("render","--no-delegated-evidence","--input",self.p("hazard.json"),"--comment",self.p("hazard.md"),"--expected",self.p("hazard-expected.json"))
|
||||
self.assertNotEqual(result.returncode,0)
|
||||
|
||||
def test_offline_verifier_rejects_reference_in_expected_body(self):
|
||||
self.expected["marker_free_body"] += "\n- unsafe: otherrepo#123"
|
||||
before_hash=self.expected["result_sha256"]
|
||||
self.expected["result_sha256"]=hashlib.sha256(self.expected["marker_free_body"].encode()).hexdigest()
|
||||
self.expected["marker"]=self.expected["marker"].replace(before_hash,self.expected["result_sha256"])
|
||||
self.expected["body"]=self.expected["marker_free_body"]+self.expected["marker"]
|
||||
self.write("expected.json",self.expected); self.readback()
|
||||
self.assertNotEqual(self.verify().returncode,0)
|
||||
|
||||
def _reseal(self, value, body):
|
||||
value=json.loads(json.dumps(value)); old=value["result_sha256"]
|
||||
value["marker_free_body"]=body
|
||||
value["result_sha256"]=hashlib.sha256(body.encode()).hexdigest()
|
||||
value["marker"]=value["marker"].replace(old,value["result_sha256"])
|
||||
value["body"]=body+value["marker"]
|
||||
return value
|
||||
|
||||
def _legacy_expected(self):
|
||||
old=json.loads(json.dumps(self.expected))
|
||||
body=old["marker_free_body"]
|
||||
body=body.replace("- Source Issue: #7","- Source Issue: 7").replace("- Pull Request: #12","- Pull Request: 12").replace("- Source Review: #99","- Source Review: 99")
|
||||
# Preserve an explicitly legacy migration fixture; no other old text differs.
|
||||
body=body.replace("- Source Issue: 7","- Source Issue: #7").replace("- Pull Request: 12","- Pull Request: #12").replace("- Source Review: 99","- Source Review: #99")
|
||||
old["marker_free_body"]=body
|
||||
old["result_sha256"]=hashlib.sha256(body.encode()).hexdigest()
|
||||
old["marker"]=old["marker"].replace(self.expected["result_sha256"],old["result_sha256"])
|
||||
old["body"]=body+old["marker"]
|
||||
return old
|
||||
|
||||
def test_prepare_correction_returns_bounded_verified_legacy_migration_plan(self):
|
||||
import importlib.util
|
||||
spec=importlib.util.spec_from_file_location("rework_comment_correction_test",SCRIPT)
|
||||
module=importlib.util.module_from_spec(spec); spec.loader.exec_module(module)
|
||||
self.assertTrue(callable(getattr(module,"prepare_correction",None)), "approved correction API missing")
|
||||
plan=module.prepare_correction(self._legacy_expected(),self.expected,comment_id=501,actor_id=42)
|
||||
self.assertEqual(plan["schema"],"gitea-rework-correction-plan/v1")
|
||||
self.assertEqual(plan["target"],self.expected["target"])
|
||||
self.assertEqual(plan["comment_id"],501); self.assertEqual(plan["actor_id"],42)
|
||||
self.assertEqual(plan["old_body"],self._legacy_expected()["body"])
|
||||
self.assertEqual(plan["new_body"],self.expected["body"])
|
||||
self.assertEqual(plan["old_body_sha256"],hashlib.sha256(plan["old_body"].encode()).hexdigest())
|
||||
self.assertEqual(plan["new_body_sha256"],hashlib.sha256(plan["new_body"].encode()).hexdigest())
|
||||
self.assertIs(plan["prior_handoff_verified"],False)
|
||||
self.assertTrue(plan["immutable_source_required"])
|
||||
|
||||
def test_prepare_correction_rejects_tampered_or_unbounded_changes(self):
|
||||
import importlib.util
|
||||
spec=importlib.util.spec_from_file_location("rework_comment_correction_reject_test",SCRIPT)
|
||||
module=importlib.util.module_from_spec(spec); spec.loader.exec_module(module)
|
||||
self.assertTrue(callable(getattr(module,"prepare_correction",None)), "approved correction API missing")
|
||||
old=self._legacy_expected()
|
||||
cases=[]
|
||||
bad=json.loads(json.dumps(old)); bad["result_sha256"]="0"*64; cases.append((bad,self.expected))
|
||||
bad=json.loads(json.dumps(old)); bad["body"]=bad["body"].replace("#7","#8",1); cases.append((bad,self.expected))
|
||||
bad=json.loads(json.dumps(old)); bad["marker_free_body"]=bad["marker_free_body"].replace("bounded fix","unsafe old text #123"); cases.append((bad,self.expected))
|
||||
bad=json.loads(json.dumps(self.expected)); bad["marker_free_body"]=bad["marker_free_body"].replace("bounded fix","different extra text"); cases.append((old,bad))
|
||||
bad=self._reseal(self.expected,self.expected["marker_free_body"].replace("bounded fix","different extra text")); cases.append((old,bad))
|
||||
unsafe_old=self._reseal(old,old["marker_free_body"].replace("bounded fix","same unsafe #123"))
|
||||
unsafe_new=self._reseal(self.expected,self.expected["marker_free_body"].replace("bounded fix","same unsafe #123")); cases.append((unsafe_old,unsafe_new))
|
||||
bad=json.loads(json.dumps(self.expected)); bad["head"]="d"*40; cases.append((old,bad))
|
||||
bad=json.loads(json.dumps(self.expected)); bad["correlation"]="e"*64; cases.append((old,bad))
|
||||
for previous,new in cases:
|
||||
with self.subTest(previous=previous["result_sha256"],new=new["correlation"]):
|
||||
with self.assertRaises(module.Bad): module.prepare_correction(previous,new,comment_id=501,actor_id=42)
|
||||
|
||||
def test_prepare_correction_cli_emits_plan_not_approval_or_receipt(self):
|
||||
self.write("previous.json",self._legacy_expected())
|
||||
r=self.invoke("prepare-correction","--previous-expected",self.p("previous.json"),"--expected",self.p("expected.json"),"--comment-id","501","--actor-id","42","--output",self.p("plan.json"))
|
||||
self.assertEqual(r.returncode,0,r.stderr)
|
||||
plan=self.read("plan.json")
|
||||
self.assertEqual(plan["schema"],"gitea-rework-correction-plan/v1")
|
||||
self.assertIs(plan["approval_required"],True)
|
||||
self.assertIs(plan["prior_handoff_verified"],False)
|
||||
self.assertNotEqual(plan.get("status"),"verified")
|
||||
self.assertEqual(self.d.joinpath("plan.json").stat().st_mode & 0o777,0o600)
|
||||
|
||||
def test_prepare_correction_cli_refuses_input_alias_and_preserves_file(self):
|
||||
self.write("previous.json",self._legacy_expected()); before=self.d.joinpath("expected.json").read_bytes()
|
||||
r=self.invoke("prepare-correction","--previous-expected",self.p("previous.json"),"--expected",self.p("expected.json"),"--comment-id","501","--actor-id","42","--output",self.p("expected.json"))
|
||||
self.assertNotEqual(r.returncode,0)
|
||||
self.assertEqual(self.d.joinpath("expected.json").read_bytes(),before)
|
||||
|
||||
def test_different_body_same_correlation_is_not_default_reuse(self):
|
||||
old=self._legacy_expected()
|
||||
self.readback([{"id":501,"html_url":"https://gitea/x/501","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z","body":old["body"],"user":{"id":42,"login":"bot"}}])
|
||||
self.assertNotEqual(self.verify("reuse",1).returncode,0)
|
||||
|
||||
if __name__ == "__main__": unittest.main()
|
||||
|
||||
@@ -131,7 +131,7 @@ class OrderedDecisionTests(unittest.TestCase):
|
||||
self.assertEqual(receipt['status'],'no_op',receipt['reason_codes'])
|
||||
self.assertEqual(receipt['decision']['selected_review_ids']['gitea-review-agent'],303)
|
||||
|
||||
def test_missing_stale_and_dismissed_current_roles_block(self):
|
||||
def test_missing_stale_and_dismissed_approvals_are_not_noop_eligible(self):
|
||||
import copy
|
||||
self.assertEqual(self.snapshot()['status'],'no_op')
|
||||
for rows in ([self.rows[0]], [self.rows[1]], []):
|
||||
@@ -317,5 +317,263 @@ class OrderedDecisionTests(unittest.TestCase):
|
||||
self.assertEqual(cmd.main(),2)
|
||||
self.assertGreater(len(requests),1);self.assertEqual(output.read_bytes(),b'created concurrently');self.assertEqual(output.stat().st_mode&0o777,0o600)
|
||||
|
||||
|
||||
def test_pr_comment_counter_mismatch_preserves_no_op_and_diagnostics(self):
|
||||
self.assertEqual(self.snapshot()['status'], 'no_op')
|
||||
self.rb.pr['comments'] += 4
|
||||
receipt = self.snapshot()
|
||||
self.assertEqual(receipt['status'], 'no_op')
|
||||
diagnostics = receipt['counter_diagnostics']
|
||||
self.assertEqual(set(diagnostics), {'inline', 'pr', 'pr_issue'})
|
||||
self.assertEqual(diagnostics['pr']['status'], 'mismatch')
|
||||
|
||||
def test_review_counter_mismatch_preserves_rework(self):
|
||||
import copy
|
||||
rows = copy.deepcopy(self.rows)
|
||||
rows[1].update(state='REQUEST_CHANGES', body=self.code_body('request_changes'))
|
||||
self.set_rows(rows)
|
||||
self.rb.pr['review_comments'] += 3
|
||||
receipt = self.snapshot()
|
||||
self.assertEqual(receipt['status'], 'rework')
|
||||
self.assertEqual(receipt['counter_diagnostics']['inline']['status'], 'mismatch')
|
||||
self.assertEqual(receipt['decision']['rework_review_ids'], [302])
|
||||
|
||||
def test_malformed_pr_counters_are_unverified_diagnostics(self):
|
||||
self.rb.pr['comments'] = 'not-a-count'
|
||||
receipt = self.snapshot()
|
||||
self.assertEqual(receipt['status'], 'no_op')
|
||||
self.assertEqual(receipt['counter_diagnostics']['pr']['status'], 'unverified')
|
||||
self.assertIn('field', receipt['counter_diagnostics']['pr'])
|
||||
|
||||
def test_unavailable_inline_endpoint_is_rejected_even_with_pr_count_fallback(self):
|
||||
from urllib.parse import urlsplit
|
||||
original = self.rb.request
|
||||
def unavailable(path):
|
||||
if '/pulls/9/reviews/' in urlsplit(path).path and urlsplit(path).path.endswith('/comments'):
|
||||
self.rb.calls.append(path)
|
||||
return {'message': 'not available'}, {}, 404
|
||||
return original(path)
|
||||
self.rb.request = unavailable
|
||||
self.rb.contract['inline']['mode'] = 'per_review_or_pr_count'
|
||||
with self.assertRaisesRegex(ValueError, 'inline collection unavailable'):
|
||||
self.snapshot()
|
||||
|
||||
def test_inline_endpoint_405_is_rejected_even_with_pr_count_fallback(self):
|
||||
from urllib.parse import urlsplit
|
||||
original = self.rb.request
|
||||
def unavailable(path):
|
||||
if '/pulls/9/reviews/' in urlsplit(path).path and urlsplit(path).path.endswith('/comments'):
|
||||
self.rb.calls.append(path)
|
||||
return {'message': 'method not allowed'}, {}, 405
|
||||
return original(path)
|
||||
self.rb.request = unavailable
|
||||
self.rb.contract['inline']['mode'] = 'per_review_or_pr_count'
|
||||
with self.assertRaisesRegex(ValueError, 'inline collection unavailable'):
|
||||
self.snapshot()
|
||||
|
||||
def test_publication_snapshot_remains_strict_on_pr_counter_mismatch(self):
|
||||
from harness.tests.impl.test_rework_readback import ReworkReadbackTests
|
||||
publication = ReworkReadbackTests()
|
||||
publication.setUp()
|
||||
self.addCleanup(publication.doCleanups)
|
||||
publication.adapter().snapshot(publication.expected)
|
||||
publication.pr['comments'] += 1
|
||||
with self.assertRaisesRegex(ValueError, 'provider aggregate count mismatch'):
|
||||
publication.adapter().snapshot(publication.expected)
|
||||
|
||||
def test_complete_lists_with_zero_pr_issue_counter_preserve_no_op(self):
|
||||
self.rb.pr['comments'] = self.rb.underlying['comments'] = 0
|
||||
receipt = self.snapshot()
|
||||
self.assertEqual(receipt['status'], 'no_op')
|
||||
for scope in ('pr', 'pr_issue'):
|
||||
diagnostic = receipt['counter_diagnostics'][scope]
|
||||
self.assertEqual(diagnostic, {'field':'comments',
|
||||
'components':['top_level','reviews','inline'], 'observed':0,
|
||||
'expected':2, 'status':'mismatch'})
|
||||
self.assertEqual(receipt['raw_input_snapshot'][scope]['comments'], 0)
|
||||
|
||||
def test_missing_and_invalid_counters_do_not_replace_record_validation(self):
|
||||
import copy
|
||||
saved_pr, saved_issue = copy.deepcopy(self.rb.pr), copy.deepcopy(self.rb.underlying)
|
||||
for scope, field in (('inline','review_comments'), ('pr','comments'), ('pr_issue','comments')):
|
||||
for value in (None, True, -1, '0', 0.5, 'missing'):
|
||||
with self.subTest(scope=scope, value=value):
|
||||
self.rb.pr, self.rb.underlying = copy.deepcopy(saved_pr), copy.deepcopy(saved_issue)
|
||||
record = self.rb.underlying if scope == 'pr_issue' else self.rb.pr
|
||||
if value == 'missing': record.pop(field)
|
||||
else: record[field] = value
|
||||
receipt = self.snapshot()
|
||||
self.assertEqual(receipt['status'], 'no_op')
|
||||
self.assertEqual(receipt['counter_diagnostics'][scope]['status'], 'unverified')
|
||||
raw_record = receipt['raw_input_snapshot']['pr_issue' if scope == 'pr_issue' else 'pr']
|
||||
if value == 'missing': self.assertNotIn(field, raw_record)
|
||||
else: self.assertEqual(raw_record[field], value)
|
||||
|
||||
def test_counter_mismatch_does_not_unblock_lone_approval(self):
|
||||
self.set_rows(self.rows[:1])
|
||||
self.rb.pr['comments'] = self.rb.underlying['comments'] = 0
|
||||
receipt = self.snapshot()
|
||||
self.assertEqual(receipt['status'], 'blocked')
|
||||
self.assertEqual(receipt['counter_diagnostics']['pr']['status'], 'mismatch')
|
||||
self.assertEqual(receipt['decision']['rework_review_ids'], [])
|
||||
|
||||
def spec_body(self, verdict, head=None):
|
||||
from harness.tests.spec import test_implementation_spec_review_support as spec_tests
|
||||
result = spec_tests.result(verdict)
|
||||
result['target'] = {'owner':'acme','repository':'demo','issue':7,'pull':9,
|
||||
'head':head or self.inp['head']}
|
||||
result['contract_sha256'] = self.inp['contract_sha256']
|
||||
if verdict == 'request_changes':
|
||||
result['criteria'][0].update(result='unsatisfied', reason='Bounded regression finding.')
|
||||
result['conclusion'] = 'Bounded correction required.'
|
||||
return spec_tests.mod.render_review(result, {'src/a.py','tests/test_a.py'}, declaration={'mode':'none'})
|
||||
|
||||
def test_lone_spec_request_changes_allows_rework(self):
|
||||
rows = [dict(self.rows[0], state='REQUEST_CHANGES', body=self.spec_body('request_changes'))]
|
||||
self.set_rows(rows)
|
||||
receipt = self.snapshot()
|
||||
self.assertEqual(receipt['status'], 'rework', receipt['reason_codes'])
|
||||
self.assertEqual(receipt['decision']['selected_review_ids']['gitea-spec-agent'], 301)
|
||||
self.assertIsNone(receipt['decision']['selected_review_ids']['gitea-review-agent'])
|
||||
self.assertEqual(receipt['decision']['rework_review_ids'], [301])
|
||||
self.assertIn('missing_current_review', receipt['reason_codes'])
|
||||
|
||||
def test_lone_code_request_changes_allows_rework(self):
|
||||
import copy
|
||||
row = copy.deepcopy(self.rows[1])
|
||||
row.update(state='REQUEST_CHANGES', body=self.code_body('request_changes'))
|
||||
self.set_rows([row])
|
||||
receipt = self.snapshot()
|
||||
self.assertEqual(receipt['status'], 'rework', receipt['reason_codes'])
|
||||
self.assertIsNone(receipt['decision']['selected_review_ids']['gitea-spec-agent'])
|
||||
self.assertEqual(receipt['decision']['selected_review_ids']['gitea-review-agent'], 302)
|
||||
self.assertEqual(receipt['decision']['rework_review_ids'], [302])
|
||||
self.assertIn('missing_current_review', receipt['reason_codes'])
|
||||
|
||||
def test_valid_request_changes_survives_invalid_counterpart_history(self):
|
||||
import copy
|
||||
for request_index in (0, 1):
|
||||
for excluded in ('stale', 'dismissed', 'old_head'):
|
||||
with self.subTest(request_role=request_index, excluded=excluded):
|
||||
rows = copy.deepcopy(self.rows)
|
||||
request = rows[request_index]
|
||||
request.update(state='REQUEST_CHANGES', body=self.spec_body('request_changes')
|
||||
if request_index == 0 else self.code_body('request_changes'))
|
||||
other = rows[1-request_index]
|
||||
if excluded == 'old_head':
|
||||
other.update(commit_id='a'*40, body=self.spec_body('approve', head='a'*40)
|
||||
if request_index == 1 else self.code_body('approve').replace(self.inp['head'], 'a'*40))
|
||||
else: other[excluded] = True
|
||||
self.set_rows(rows)
|
||||
receipt = self.snapshot()
|
||||
self.assertEqual(receipt['status'], 'rework', receipt['reason_codes'])
|
||||
self.assertEqual(receipt['decision']['rework_review_ids'], [request['id']])
|
||||
self.assertIsNone(receipt['decision']['selected_review_ids'][other['user']['login']])
|
||||
self.assertIn('missing_current_review', receipt['reason_codes'])
|
||||
|
||||
def test_excluded_lone_requests_cannot_authorize_rework(self):
|
||||
import copy
|
||||
for request_index in (0, 1):
|
||||
for excluded in ('stale', 'dismissed', 'old_head'):
|
||||
with self.subTest(request_role=request_index, excluded=excluded):
|
||||
request = copy.deepcopy(self.rows[request_index])
|
||||
head = 'a'*40 if excluded == 'old_head' else self.inp['head']
|
||||
request.update(state='REQUEST_CHANGES', commit_id=head,
|
||||
body=self.spec_body('request_changes', head=head) if request_index == 0
|
||||
else self.code_body('request_changes').replace(self.inp['head'], head))
|
||||
if excluded != 'old_head': request[excluded] = True
|
||||
self.set_rows([request])
|
||||
receipt = self.snapshot()
|
||||
self.assertEqual(receipt['status'], 'blocked')
|
||||
self.assertEqual(receipt['decision']['rework_review_ids'], [])
|
||||
|
||||
def test_superseded_request_with_missing_counterpart_stays_blocked(self):
|
||||
import copy
|
||||
request = copy.deepcopy(self.rows[1])
|
||||
request.update(state='REQUEST_CHANGES', body=self.code_body('request_changes'))
|
||||
approval = copy.deepcopy(self.rows[1])
|
||||
approval.update(id=303, submitted_at='2026-01-02T00:00:00Z', updated_at='2026-01-02T00:00:00Z',
|
||||
html_url=self.rb.pr['html_url'] + '#pullreview-303')
|
||||
self.set_rows([request, approval])
|
||||
receipt = self.snapshot()
|
||||
self.assertEqual(receipt['status'], 'blocked', receipt['reason_codes'])
|
||||
self.assertNotEqual(receipt['status'], 'rework')
|
||||
self.assertNotEqual(receipt['status'], 'no_op')
|
||||
self.assertEqual(receipt['decision']['rework_review_ids'], [])
|
||||
|
||||
def test_true_blockers_override_valid_request_changes(self):
|
||||
import copy
|
||||
request = copy.deepcopy(self.rows[1])
|
||||
request.update(state='REQUEST_CHANGES', body=self.code_body('request_changes'))
|
||||
for state in ('COMMENT', 'PENDING'):
|
||||
with self.subTest(state=state):
|
||||
rows = copy.deepcopy(self.rows)
|
||||
rows[1] = copy.deepcopy(request)
|
||||
rows[0]['state'] = state
|
||||
self.set_rows(rows)
|
||||
receipt = self.snapshot()
|
||||
self.assertEqual(receipt['status'], 'blocked')
|
||||
self.assertIn('comment_manual_classification' if state == 'COMMENT'
|
||||
else 'pending_authoritative_review', receipt['reason_codes'])
|
||||
rows = copy.deepcopy(self.rows)
|
||||
rows[1] = request
|
||||
rows[0]['body'] = 'missing marker'
|
||||
self.set_rows(rows)
|
||||
self.assertEqual(self.snapshot()['status'], 'blocked')
|
||||
rows = copy.deepcopy(self.rows)
|
||||
rows[1] = request
|
||||
rows[0]['body'] = rows[0]['body'].replace(self.inp['contract_sha256'], 'f'*64)
|
||||
self.set_rows(rows)
|
||||
self.assertEqual(self.snapshot()['status'], 'blocked')
|
||||
rows = copy.deepcopy(self.rows)
|
||||
result = __import__('harness.tests.spec.test_implementation_spec_review_support', fromlist=['result'])
|
||||
spec_result = result.result('blocked_spec')
|
||||
spec_result['target'] = {'owner':'acme','repository':'demo','issue':7,'pull':9,'head':self.inp['head']}
|
||||
spec_result['contract_sha256'] = self.inp['contract_sha256']
|
||||
spec_result['criteria'][0]['result'] = 'indeterminate'
|
||||
spec_result['scope']['spec_conflicts'] = ['src/a.py']
|
||||
rows[0].update(state='COMMENT', body=result.mod.render_review(spec_result, {'src/a.py','tests/test_a.py'}, declaration={'mode':'none'}))
|
||||
rows[1] = request
|
||||
self.set_rows(rows)
|
||||
receipt = self.snapshot()
|
||||
self.assertEqual(receipt['status'], 'blocked')
|
||||
self.assertIn('blocked_spec', receipt['reason_codes'])
|
||||
|
||||
def test_lone_request_changes_keeps_rework_despite_counter_mismatch(self):
|
||||
import copy
|
||||
request = copy.deepcopy(self.rows[1])
|
||||
request.update(state='REQUEST_CHANGES', body=self.code_body('request_changes'))
|
||||
self.set_rows([request])
|
||||
self.rb.pr['comments'] += 2
|
||||
receipt = self.snapshot()
|
||||
self.assertEqual(receipt['status'], 'rework', receipt['reason_codes'])
|
||||
self.assertEqual(receipt['decision']['rework_review_ids'], [302])
|
||||
self.assertEqual(receipt['counter_diagnostics']['pr']['status'], 'mismatch')
|
||||
|
||||
def test_counter_mismatch_does_not_hide_per_review_inline_count_mismatch(self):
|
||||
self.rb.reviews[0]['comments_count'] = 1
|
||||
self.rb.pr['comments'] = self.rb.underlying['comments'] = 0
|
||||
with self.assertRaisesRegex(ValueError, 'per-review inline count mismatch'):
|
||||
self.snapshot()
|
||||
|
||||
def test_counter_mismatch_does_not_hide_duplicate_review_records(self):
|
||||
import copy
|
||||
self.rb.reviews.append(copy.deepcopy(self.rb.reviews[0]))
|
||||
self.rb.pr['comments'] = self.rb.underlying['comments'] = 0
|
||||
with self.assertRaisesRegex(ValueError, 'duplicate'):
|
||||
self.snapshot()
|
||||
|
||||
def test_counter_mismatch_does_not_hide_incomplete_review_pagination(self):
|
||||
original = self.rb.request
|
||||
def truncated(path):
|
||||
if '/pulls/9/reviews?' in path:
|
||||
payload, headers, status = original(path)
|
||||
return payload, dict(headers, **{'X-Total-Count':'3'}), status
|
||||
return original(path)
|
||||
self.rb.request = truncated
|
||||
self.rb.pr['comments'] = self.rb.underlying['comments'] = 0
|
||||
with self.assertRaisesRegex(ValueError, 'truncated collection'):
|
||||
self.snapshot()
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
@@ -105,6 +105,74 @@ class ReworkReadbackTests(unittest.TestCase):
|
||||
self.underlying['comments'] += 1
|
||||
self.pr['updated_at'] = self.underlying['updated_at'] = T1
|
||||
|
||||
def test_review_only_projection_preserves_inline_records_without_counting_them(self):
|
||||
module = self.load('rework_readback')
|
||||
contract = copy.deepcopy(self.contract)
|
||||
contract['inline']['components'] = ['reviews']
|
||||
adapter = module.ReworkReadbackAdapter(self.request, origin=ORIGIN+'/api/v1',
|
||||
credential_source='$HERMES_HOME/.env:GITEA_TOKEN', actor_id=42, provider_contract=contract)
|
||||
self.pr['review_comments'] = 1
|
||||
self.reviews[0]['comments_count'] = 0
|
||||
snapshot = adapter.snapshot(self.expected)
|
||||
self.assertEqual(snapshot['normalized_counts']['inline'], 0)
|
||||
self.reviews[0]['comments_count'] = 1
|
||||
self.inline = [{'id':801,'body':'inline','created_at':T0,'updated_at':T0,
|
||||
'user':{'id':33,'login':'gitea-review-agent'}}]
|
||||
self.pr['review_comments'] = 1
|
||||
self.pr['comments'] = self.underlying['comments'] = 2
|
||||
snapshot = adapter.snapshot(self.expected)
|
||||
self.assertEqual(snapshot['normalized_counts']['inline'], 1)
|
||||
|
||||
def test_review_only_projection_rejects_counter_mismatch(self):
|
||||
module = self.load('rework_readback')
|
||||
contract = copy.deepcopy(self.contract)
|
||||
contract['inline']['components'] = ['reviews']
|
||||
adapter = module.ReworkReadbackAdapter(self.request, origin=ORIGIN+'/api/v1',
|
||||
credential_source='$HERMES_HOME/.env:GITEA_TOKEN', actor_id=42, provider_contract=contract)
|
||||
self.reviews[0]['comments_count'] = 1
|
||||
self.inline = [{'id':801,'body':'inline','created_at':T0,'updated_at':T0,
|
||||
'user':{'id':33,'login':'gitea-review-agent'}}]
|
||||
self.pr['review_comments'] = 0
|
||||
with self.assertRaisesRegex(ValueError, 'inline provider counter crosscheck failed'):
|
||||
adapter.snapshot(self.expected)
|
||||
|
||||
def test_inline_component_contract_validation_remains_strict(self):
|
||||
module = self.load('rework_readback')
|
||||
for parts in ([], None, 'reviews', ['inline','inline'], ['reviews','reviews'],
|
||||
['unknown'], ['reviews','unknown'], ['reviews','inline','reviews']):
|
||||
contract = copy.deepcopy(self.contract)
|
||||
contract['inline']['components'] = parts
|
||||
with self.subTest(components=parts), self.assertRaises(ValueError):
|
||||
module.ReworkReadbackAdapter(self.request, origin=ORIGIN+'/api/v1',
|
||||
credential_source='$HERMES_HOME/.env:GITEA_TOKEN', actor_id=42, provider_contract=contract)
|
||||
|
||||
def test_review_only_projection_retains_inline_evidence_rejections(self):
|
||||
module = self.load('rework_readback')
|
||||
contract = copy.deepcopy(self.contract)
|
||||
contract['inline']['components'] = ['reviews']
|
||||
adapter = module.ReworkReadbackAdapter(self.request, origin=ORIGIN+'/api/v1',
|
||||
credential_source='$HERMES_HOME/.env:GITEA_TOKEN', actor_id=42, provider_contract=contract)
|
||||
self.reviews[0]['comments_count'] = 1
|
||||
self.inline = [{'id':801,'body':'inline','created_at':T0,'updated_at':T0,
|
||||
'user':{'id':33,'login':'gitea-review-agent'}}]
|
||||
self.pr['review_comments'] = 2
|
||||
with self.assertRaises(ValueError): adapter.snapshot(self.expected)
|
||||
self.pr['review_comments'] = 1
|
||||
self.inline = []
|
||||
with self.assertRaisesRegex(ValueError, 'per-review inline count mismatch'):
|
||||
adapter.snapshot(self.expected)
|
||||
self.inline = [{'id':801,'body':'inline','created_at':T0,'updated_at':T0,
|
||||
'user':{'id':33,'login':'gitea-review-agent'}},
|
||||
{'id':801,'body':'duplicate','created_at':T0,'updated_at':T0,
|
||||
'user':{'id':33,'login':'gitea-review-agent'}}]
|
||||
with self.assertRaisesRegex(ValueError, 'duplicate'):
|
||||
adapter.snapshot(self.expected)
|
||||
self.inline = [{'id':801,'body':'inline','created_at':T0,'updated_at':T0,
|
||||
'user':{'id':33,'login':'gitea-review-agent'}}]
|
||||
self.overrides = {'/repos/acme/demo/pulls/9/reviews/301/comments?page=1&limit=50':
|
||||
(self.inline, {'X-Total-Count':'2'}, 200)}
|
||||
with self.assertRaises(ValueError): adapter.snapshot(self.expected)
|
||||
|
||||
def test_snapshot_rejects_wrong_provider_bindings(self):
|
||||
cases = [('repo',['id'],True), ('repo',['full_name'],'elsewhere/demo'),
|
||||
('repo',['html_url'],'https://foreign.invalid/acme/demo'),
|
||||
@@ -117,7 +185,6 @@ class ReworkReadbackTests(unittest.TestCase):
|
||||
('pr',['state'],'closed'), ('underlying',['number'],10),
|
||||
('underlying',['pull_request'],True), ('reviews',[0,'commit_id'],'c'*40)]
|
||||
error = self.load('rework_readback').ReadbackError
|
||||
# Each load has a distinct exception class; assert ValueError at the public boundary.
|
||||
self.assertTrue(issubclass(error, ValueError))
|
||||
for attribute, keys, value in cases:
|
||||
with self.subTest(attribute=attribute, keys=keys):
|
||||
@@ -145,7 +212,7 @@ class ReworkReadbackTests(unittest.TestCase):
|
||||
(first,self.comments[:50],{},200),
|
||||
(first,[{'id':True}],{'X-Total-Count':'1'},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:
|
||||
with self.subTest(path=path, headers=headers, status=status):
|
||||
self.overrides = {path:(rows,headers,status)}
|
||||
@@ -231,7 +298,6 @@ class ReworkReadbackTests(unittest.TestCase):
|
||||
baseline = adapter.snapshot(self.expected)
|
||||
self.add_result()
|
||||
after = adapter.snapshot(self.expected)
|
||||
# Prove this exact clean fixture is admitted before adversarial mutation.
|
||||
adapter.normalized_inputs(self.expected, baseline, after, 'create')
|
||||
cases = [(['schema'],'forged'), (['actor_id'],True), (['origin'],'https://foreign.invalid/api/v1'),
|
||||
(['target','owner'],'other'), (['provider','version'],'different'),
|
||||
@@ -390,7 +456,6 @@ class ReworkReadbackTests(unittest.TestCase):
|
||||
with self.assertRaises(ValueError): self.adapter().snapshot(self.expected)
|
||||
|
||||
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.contract['inline']['components'] = ['inline']
|
||||
self.pr.pop('review_comments')
|
||||
@@ -475,3 +540,167 @@ class ReworkReadbackTests(unittest.TestCase):
|
||||
reused = adapter.normalized_inputs(self.expected, after, adapter.snapshot(self.expected), 'reuse')
|
||||
self.assertEqual(self.legacy.verify_readbacks(self.expected, reused['pr'], reused['comments'],
|
||||
reused['identity'], action='reuse', baseline_count=reused['baseline_count'])['status'], 'verified')
|
||||
|
||||
def _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