Compare commits

..
5 Commits
10 changed files with 933 additions and 54 deletions
@@ -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')
+6
View File
@@ -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는 구현 결과가 안전하게 전달되거나 명시적으로 폐기될 때까지 세션 간 보존한다.
+113
View File
@@ -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()
+259 -1
View File
@@ -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()
+233 -4
View File
@@ -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)