claude-dev-env 8.29.0 → 8.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/codex-cleanse/config/constants.test.mjs +42 -0
- package/.agents/skills/e-code-review/SKILL.md +15 -0
- package/package.json +1 -1
- package/scripts/codex_review_observer.py +285 -0
- package/scripts/dev_env_scripts_constants/codex_review_observer_constants.py +17 -0
- package/scripts/tests/fixtures/codex-pilot-findings.json +14 -0
- package/scripts/tests/fixtures/codex-quota-comment.json +8 -0
- package/scripts/tests/fixtures/codex-quota-credits-comment.json +8 -0
- package/scripts/tests/fixtures/codex-submitted-findings.json +15 -0
- package/scripts/tests/test_codex_review_guidance.py +15 -0
- package/scripts/tests/test_codex_review_observer.py +357 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_INACTIVE_DAYS,
|
|
6
|
+
MILLISECONDS_PER_DAY,
|
|
7
|
+
MILLISECONDS_PER_SECOND,
|
|
8
|
+
PAGE_SIZE,
|
|
9
|
+
REQUEST_TIMEOUT_MILLISECONDS,
|
|
10
|
+
ROLLOUT_FILENAME_PATTERN,
|
|
11
|
+
SOURCE_KINDS,
|
|
12
|
+
THREAD_ID_PATTERN,
|
|
13
|
+
} from './constants.mjs';
|
|
14
|
+
|
|
15
|
+
test('cleanse defaults to a seven-day cutoff and bounded requests', () => {
|
|
16
|
+
assert.equal(DEFAULT_INACTIVE_DAYS, 7);
|
|
17
|
+
assert.equal(MILLISECONDS_PER_DAY, 86_400_000);
|
|
18
|
+
assert.equal(MILLISECONDS_PER_SECOND, 1_000);
|
|
19
|
+
assert.equal(PAGE_SIZE, 1_000);
|
|
20
|
+
assert.equal(REQUEST_TIMEOUT_MILLISECONDS, 30_000);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('session listing includes every supported source kind', () => {
|
|
24
|
+
assert.deepEqual(SOURCE_KINDS, [
|
|
25
|
+
'cli', 'vscode', 'exec', 'appServer', 'subAgent', 'subAgentReview',
|
|
26
|
+
'subAgentCompact', 'subAgentThreadSpawn', 'subAgentOther', 'unknown',
|
|
27
|
+
]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('thread IDs accept UUID spelling and reject malformed IDs', () => {
|
|
31
|
+
assert.equal(THREAD_ID_PATTERN.test('12345678-1234-ABCD-1234-123456789ABC'), true);
|
|
32
|
+
assert.equal(THREAD_ID_PATTERN.test('12345678-1234-ABCD-1234-123456789AB'), false);
|
|
33
|
+
assert.equal(THREAD_ID_PATTERN.test('prefix-12345678-1234-ABCD-1234-123456789ABC'), false);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('rollout filenames yield the thread ID and reject other files', () => {
|
|
37
|
+
const threadId = '12345678-1234-ABCD-1234-123456789ABC';
|
|
38
|
+
const filename = `rollout-2026-09-30T12-34-56-${threadId}.jsonl`;
|
|
39
|
+
assert.equal(ROLLOUT_FILENAME_PATTERN.exec(filename)?.[1], threadId);
|
|
40
|
+
assert.equal(ROLLOUT_FILENAME_PATTERN.test(`${filename}.bak`), false);
|
|
41
|
+
assert.equal(ROLLOUT_FILENAME_PATTERN.test(`other-${filename}`), false);
|
|
42
|
+
});
|
|
@@ -36,6 +36,21 @@ Each level file reports through the ReportFindings tool. When the host has no Re
|
|
|
36
36
|
|
|
37
37
|
## The process
|
|
38
38
|
|
|
39
|
+
### Native GitHub evidence pilot
|
|
40
|
+
|
|
41
|
+
Keep one coordinator responsible for native review requests. Record the request comment and full candidate head. Read raw GitHub review records to retain commit_id; normalized connector output can omit it. Completed reviews can contain findings. The quota_notice_ids field records historical usage-limit comments. Driver replies and resolved threads provide no clean completion proof.
|
|
42
|
+
|
|
43
|
+
Use process-local GITHUB_TOKEN or GH_TOKEN authentication without printing credentials. A code-review usage-limit response stops further triggers until capacity is verified. Preserve the hold; review permission changes and credit purchases require separate authorization. Capture API bytes as UTF-8 before decoding them so Windows shell output encoding preserves review headings.
|
|
44
|
+
|
|
45
|
+
Inspect a candidate from the shared source checkout:
|
|
46
|
+
|
|
47
|
+
```powershell
|
|
48
|
+
python packages/claude-dev-env/scripts/codex_review_observer.py <owner/repository> <pull-request> <full-head>
|
|
49
|
+
python -m pytest packages/claude-dev-env/scripts/tests/test_codex_review_observer.py packages/claude-dev-env/scripts/tests/test_codex_review_guidance.py -q --override-ini="addopts=" -p no:cacheprovider
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The current observer holds admission for every supported diagnostic. Require captured successful native completion with full-head and attempt coverage before adding a clean verdict. After a repair push, review the new head and preserve all existing checks. Keep current repository contracts authoritative over copied hosted policy.
|
|
53
|
+
|
|
39
54
|
1. Read the level and the optional `--fix` flag. Load the level file.
|
|
40
55
|
2. Run it end to end, ending in its ReportFindings call.
|
|
41
56
|
3. With `--fix`, load `reference/fix.md` and run it on those findings.
|
package/package.json
CHANGED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""Observe candidate reviews without granting admission to a merge queue.
|
|
2
|
+
|
|
3
|
+
Native completion representation and reviewer identity remain unverified.
|
|
4
|
+
Every observation holds admission, including plausible success records.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
import sys
|
|
13
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
from enum import Enum
|
|
17
|
+
from urllib.error import URLError
|
|
18
|
+
|
|
19
|
+
from dev_env_scripts_constants.codex_review_observer_constants import (
|
|
20
|
+
CODEX_FINDINGS_HEADING,
|
|
21
|
+
CODEX_FINDINGS_PREFIX,
|
|
22
|
+
CODEX_QUOTA_PREFIX,
|
|
23
|
+
CODEX_REVIEWER_LOGIN,
|
|
24
|
+
COMMENTS_URL,
|
|
25
|
+
COMMIT_PATTERN,
|
|
26
|
+
HOLD_EXIT_CODE,
|
|
27
|
+
MAX_PAGES,
|
|
28
|
+
PAGE_SIZE,
|
|
29
|
+
PULL_URL,
|
|
30
|
+
REPOSITORY_PATTERN,
|
|
31
|
+
REVIEWS_SUFFIX,
|
|
32
|
+
UNAVAILABLE_EXIT_CODE,
|
|
33
|
+
)
|
|
34
|
+
from pr_verification.github_parsing import GitHubError
|
|
35
|
+
from review_closure_github import github_token, request_json
|
|
36
|
+
|
|
37
|
+
GitHubRead = Callable[[str], object]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class HoldReason(str, Enum):
|
|
41
|
+
MISSING_REVIEW = "missing_review"
|
|
42
|
+
STALE_REVIEW = "stale_review"
|
|
43
|
+
HEAD_CHANGED = "head_changed"
|
|
44
|
+
NATIVE_FORMAT_UNVERIFIED = "native_format_unverified"
|
|
45
|
+
READ_UNAVAILABLE = "read_unavailable"
|
|
46
|
+
READ_TIMEOUT = "read_timeout"
|
|
47
|
+
INCOMPLETE_READ = "incomplete_read"
|
|
48
|
+
CONFLICTING_EVIDENCE = "conflicting_evidence"
|
|
49
|
+
REVIEW_FINDINGS = "review_findings"
|
|
50
|
+
QUOTA_NOTICE = "quota_notice"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class Candidate:
|
|
55
|
+
repository: str
|
|
56
|
+
pull_request: int
|
|
57
|
+
head_sha: str
|
|
58
|
+
|
|
59
|
+
def __post_init__(self) -> None:
|
|
60
|
+
if (
|
|
61
|
+
re.fullmatch(REPOSITORY_PATTERN, self.repository) is None
|
|
62
|
+
or type(self.pull_request) is not int
|
|
63
|
+
or self.pull_request <= 0
|
|
64
|
+
or re.fullmatch(COMMIT_PATTERN, self.head_sha) is None
|
|
65
|
+
):
|
|
66
|
+
raise ValueError("A repository, pull request, and full head are required.")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class ReviewObservation:
|
|
71
|
+
candidate: Candidate
|
|
72
|
+
reason: HoldReason
|
|
73
|
+
evidence_ids: tuple[int, ...] = ()
|
|
74
|
+
quota_notice_ids: tuple[int, ...] = ()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _matches_candidate(document: object, candidate: Candidate) -> bool:
|
|
78
|
+
if not isinstance(document, Mapping):
|
|
79
|
+
return False
|
|
80
|
+
head = document.get("head")
|
|
81
|
+
return (
|
|
82
|
+
document.get("number") == candidate.pull_request
|
|
83
|
+
and isinstance(head, Mapping)
|
|
84
|
+
and head.get("sha") == candidate.head_sha
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _is_submitted_codex_findings(all_review: Mapping[str, object]) -> bool:
|
|
89
|
+
author = all_review.get("user")
|
|
90
|
+
body = all_review.get("body")
|
|
91
|
+
submitted_at = all_review.get("submitted_at")
|
|
92
|
+
if not (
|
|
93
|
+
isinstance(author, Mapping)
|
|
94
|
+
and author.get("login") == CODEX_REVIEWER_LOGIN
|
|
95
|
+
and all_review.get("state") == "COMMENTED"
|
|
96
|
+
and isinstance(body, str)
|
|
97
|
+
and body.lstrip().startswith((CODEX_FINDINGS_PREFIX, CODEX_FINDINGS_HEADING))
|
|
98
|
+
and isinstance(submitted_at, str)
|
|
99
|
+
):
|
|
100
|
+
return False
|
|
101
|
+
try:
|
|
102
|
+
return datetime.fromisoformat(submitted_at).tzinfo is not None
|
|
103
|
+
except ValueError:
|
|
104
|
+
return False
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class _EvidenceReadRunFatal(Exception):
|
|
108
|
+
def __init__(self, reason: HoldReason) -> None:
|
|
109
|
+
super().__init__(reason.value)
|
|
110
|
+
self.reason = reason
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _merge_page(all_records: dict[int, Mapping[str, object]], all_page: object) -> int:
|
|
114
|
+
if not isinstance(all_page, list):
|
|
115
|
+
raise _EvidenceReadRunFatal(HoldReason.INCOMPLETE_READ)
|
|
116
|
+
for each_record in all_page:
|
|
117
|
+
if not isinstance(each_record, Mapping):
|
|
118
|
+
raise _EvidenceReadRunFatal(HoldReason.INCOMPLETE_READ)
|
|
119
|
+
record_id = each_record.get("id")
|
|
120
|
+
if type(record_id) is not int or record_id <= 0:
|
|
121
|
+
raise _EvidenceReadRunFatal(HoldReason.INCOMPLETE_READ)
|
|
122
|
+
if record_id in all_records and all_records[record_id] != each_record:
|
|
123
|
+
raise _EvidenceReadRunFatal(HoldReason.CONFLICTING_EVIDENCE)
|
|
124
|
+
all_records[record_id] = each_record
|
|
125
|
+
return len(all_page)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _read_collection(url: str, read: GitHubRead) -> dict[int, Mapping[str, object]]:
|
|
129
|
+
all_records: dict[int, Mapping[str, object]] = {}
|
|
130
|
+
for each_page in range(1, MAX_PAGES + 1):
|
|
131
|
+
count = _merge_page(
|
|
132
|
+
all_records, read(f"{url}?per_page={PAGE_SIZE}&page={each_page}")
|
|
133
|
+
)
|
|
134
|
+
if count < PAGE_SIZE:
|
|
135
|
+
return all_records
|
|
136
|
+
raise _EvidenceReadRunFatal(HoldReason.INCOMPLETE_READ)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _quota_notice_ids(
|
|
140
|
+
all_comments: Mapping[int, Mapping[str, object]],
|
|
141
|
+
) -> tuple[int, ...]:
|
|
142
|
+
all_ids: list[int] = []
|
|
143
|
+
for each_id, each_comment in all_comments.items():
|
|
144
|
+
author = each_comment.get("user")
|
|
145
|
+
body = each_comment.get("body")
|
|
146
|
+
if (
|
|
147
|
+
isinstance(author, Mapping)
|
|
148
|
+
and author.get("login") == CODEX_REVIEWER_LOGIN
|
|
149
|
+
and isinstance(body, str)
|
|
150
|
+
and body.startswith(CODEX_QUOTA_PREFIX)
|
|
151
|
+
):
|
|
152
|
+
all_ids.append(each_id)
|
|
153
|
+
return tuple(sorted(all_ids))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _review_observation(
|
|
157
|
+
candidate: Candidate, all_reviews: Mapping[int, Mapping[str, object]]
|
|
158
|
+
) -> ReviewObservation:
|
|
159
|
+
all_current = {
|
|
160
|
+
each_id: each_review
|
|
161
|
+
for each_id, each_review in all_reviews.items()
|
|
162
|
+
if each_review.get("commit_id") == candidate.head_sha
|
|
163
|
+
}
|
|
164
|
+
reason = HoldReason.MISSING_REVIEW
|
|
165
|
+
if all_reviews:
|
|
166
|
+
reason = HoldReason.STALE_REVIEW
|
|
167
|
+
if all_current:
|
|
168
|
+
reason = HoldReason.NATIVE_FORMAT_UNVERIFIED
|
|
169
|
+
if any(
|
|
170
|
+
_is_submitted_codex_findings(each_review)
|
|
171
|
+
for each_review in all_current.values()
|
|
172
|
+
):
|
|
173
|
+
reason = HoldReason.REVIEW_FINDINGS
|
|
174
|
+
return ReviewObservation(candidate, reason, tuple(sorted(all_current)))
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _stable_observation(
|
|
178
|
+
candidate: Candidate, read: GitHubRead, pull_url: str
|
|
179
|
+
) -> ReviewObservation:
|
|
180
|
+
all_reviews = _read_collection(pull_url + REVIEWS_SUFFIX, read)
|
|
181
|
+
observation = _review_observation(candidate, all_reviews)
|
|
182
|
+
comments_url = COMMENTS_URL.format(
|
|
183
|
+
repository=candidate.repository, number=candidate.pull_request
|
|
184
|
+
)
|
|
185
|
+
all_quota_ids = _quota_notice_ids(_read_collection(comments_url, read))
|
|
186
|
+
reason = observation.reason
|
|
187
|
+
if all_quota_ids and reason in (
|
|
188
|
+
HoldReason.MISSING_REVIEW,
|
|
189
|
+
HoldReason.STALE_REVIEW,
|
|
190
|
+
):
|
|
191
|
+
reason = HoldReason.QUOTA_NOTICE
|
|
192
|
+
return ReviewObservation(candidate, reason, observation.evidence_ids, all_quota_ids)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _transport_failure_reason(failure: BaseException) -> HoldReason:
|
|
196
|
+
cause = failure.__cause__
|
|
197
|
+
if isinstance(failure, TimeoutError) or isinstance(cause, TimeoutError):
|
|
198
|
+
return HoldReason.READ_TIMEOUT
|
|
199
|
+
if isinstance(cause, URLError) and isinstance(cause.reason, TimeoutError):
|
|
200
|
+
return HoldReason.READ_TIMEOUT
|
|
201
|
+
return HoldReason.READ_UNAVAILABLE
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def observe_codex_review(candidate: Candidate, read: GitHubRead) -> ReviewObservation:
|
|
205
|
+
"""Read evidence for a stable full head and return its admission hold.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
candidate: The repository, pull request, and requested full head.
|
|
209
|
+
read: Authenticated read-only JSON transport.
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
The observed hold and evidence identifiers.
|
|
213
|
+
"""
|
|
214
|
+
pull_url = PULL_URL.format(
|
|
215
|
+
repository=candidate.repository, number=candidate.pull_request
|
|
216
|
+
)
|
|
217
|
+
observation = ReviewObservation(candidate, HoldReason.MISSING_REVIEW)
|
|
218
|
+
try:
|
|
219
|
+
if not _matches_candidate(read(pull_url), candidate):
|
|
220
|
+
return ReviewObservation(candidate, HoldReason.HEAD_CHANGED)
|
|
221
|
+
try:
|
|
222
|
+
observation = _stable_observation(candidate, read, pull_url)
|
|
223
|
+
except _EvidenceReadRunFatal as failure:
|
|
224
|
+
observation = ReviewObservation(candidate, failure.reason)
|
|
225
|
+
if not _matches_candidate(read(pull_url), candidate):
|
|
226
|
+
return ReviewObservation(candidate, HoldReason.HEAD_CHANGED)
|
|
227
|
+
except TimeoutError:
|
|
228
|
+
return ReviewObservation(candidate, HoldReason.READ_TIMEOUT)
|
|
229
|
+
except (GitHubError, OSError) as failure:
|
|
230
|
+
return ReviewObservation(candidate, _transport_failure_reason(failure))
|
|
231
|
+
return observation
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _observation_document(observation: ReviewObservation) -> dict[str, object]:
|
|
235
|
+
candidate = observation.candidate
|
|
236
|
+
return {
|
|
237
|
+
"repository": candidate.repository,
|
|
238
|
+
"pull_request": candidate.pull_request,
|
|
239
|
+
"head_sha": candidate.head_sha,
|
|
240
|
+
"admission": "hold",
|
|
241
|
+
"reason": observation.reason.value,
|
|
242
|
+
"evidence_ids": observation.evidence_ids,
|
|
243
|
+
"quota_notice_ids": observation.quota_notice_ids,
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _parse_candidate(all_arguments: Sequence[str]) -> Candidate:
|
|
248
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
249
|
+
parser.add_argument("repository")
|
|
250
|
+
parser.add_argument("pull_request", type=int)
|
|
251
|
+
parser.add_argument("head_sha")
|
|
252
|
+
parsed = parser.parse_args(all_arguments)
|
|
253
|
+
return Candidate(parsed.repository, parsed.pull_request, parsed.head_sha)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def main(all_arguments: Sequence[str]) -> int:
|
|
257
|
+
"""Print a read-only observation and return nonzero for every hold.
|
|
258
|
+
|
|
259
|
+
Args:
|
|
260
|
+
all_arguments: Command arguments without the executable name.
|
|
261
|
+
|
|
262
|
+
Returns:
|
|
263
|
+
One for a hold, or two when the read is unavailable.
|
|
264
|
+
"""
|
|
265
|
+
try:
|
|
266
|
+
candidate = _parse_candidate(all_arguments)
|
|
267
|
+
token = github_token()
|
|
268
|
+
except (ValueError, GitHubError) as failure:
|
|
269
|
+
print(str(failure), file=sys.stderr)
|
|
270
|
+
return UNAVAILABLE_EXIT_CODE
|
|
271
|
+
|
|
272
|
+
def read(url: str) -> object:
|
|
273
|
+
return request_json("GET", url, token, None)
|
|
274
|
+
|
|
275
|
+
observation = observe_codex_review(candidate, read)
|
|
276
|
+
print(json.dumps(_observation_document(observation)))
|
|
277
|
+
return (
|
|
278
|
+
UNAVAILABLE_EXIT_CODE
|
|
279
|
+
if observation.reason in (HoldReason.READ_TIMEOUT, HoldReason.READ_UNAVAILABLE)
|
|
280
|
+
else HOLD_EXIT_CODE
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
if __name__ == "__main__":
|
|
285
|
+
raise SystemExit(main(sys.argv[1:]))
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Bounds for read-only Codex review observations."""
|
|
2
|
+
|
|
3
|
+
GITHUB_API_ROOT = "https://api.github.com"
|
|
4
|
+
PAGE_SIZE = 100
|
|
5
|
+
MAX_PAGES = 20
|
|
6
|
+
COMMIT_PATTERN = r"[0-9a-f]{40}"
|
|
7
|
+
REPOSITORY_PATTERN = r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+"
|
|
8
|
+
HOLD_EXIT_CODE = 1
|
|
9
|
+
UNAVAILABLE_EXIT_CODE = 2
|
|
10
|
+
CODEX_REVIEWER_LOGIN = "chatgpt-codex-connector[bot]"
|
|
11
|
+
CODEX_FINDINGS_PREFIX = "Here are some automated review suggestions"
|
|
12
|
+
CODEX_FINDINGS_HEADING = "### 💡 Codex Review\n\n" + CODEX_FINDINGS_PREFIX
|
|
13
|
+
|
|
14
|
+
PULL_URL = GITHUB_API_ROOT + "/repos/{repository}/pulls/{number}"
|
|
15
|
+
COMMENTS_URL = GITHUB_API_ROOT + "/repos/{repository}/issues/{number}/comments"
|
|
16
|
+
REVIEWS_SUFFIX = "/reviews"
|
|
17
|
+
CODEX_QUOTA_PREFIX = "You have reached your Codex usage limits for code reviews."
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"repository": "jl-cmd/claude-dev-env",
|
|
3
|
+
"pull_request": 1576,
|
|
4
|
+
"review": {
|
|
5
|
+
"id": 5360553725,
|
|
6
|
+
"state": "COMMENTED",
|
|
7
|
+
"commit_id": "d66450a145554778c9938c310cae61cca5927884",
|
|
8
|
+
"submitted_at": "2026-09-30T01:50:30Z",
|
|
9
|
+
"body": "\n### \ud83d\udca1 Codex Review\n\nHere are some automated review suggestions for this pull request.\n\n**Reviewed commit:** `d66450a145`\n \n\n<details> <summary>\u2139\ufe0f About Codex in GitHub</summary>\n<br/>\n\n[Your team has set up Codex to review pull requests in this repo](https://chatgpt.com/codex/cloud/settings/general). Reviews are triggered when you\n- Open a pull request for review\n- Mark a draft as ready\n- Comment \"@codex review\".\n\nIf Codex has suggestions, it will comment; otherwise it will react with \ud83d\udc4d.\n\n\n\n\nCodex can also answer questions or update the PR. Try commenting \"@codex address that feedback\".\n \n</details>",
|
|
10
|
+
"user": {
|
|
11
|
+
"login": "chatgpt-codex-connector[bot]"
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
{
|
|
2
|
+
"body": "You have reached your Codex usage limits for code reviews. You can see your limits in the [Codex usage dashboard](https://chatgpt.com/codex/cloud/settings/usage).",
|
|
3
|
+
"created_at": "2026-09-30T00:12:01Z",
|
|
4
|
+
"id": 5901476928,
|
|
5
|
+
"user": {
|
|
6
|
+
"login": "chatgpt-codex-connector[bot]"
|
|
7
|
+
}
|
|
8
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
{
|
|
2
|
+
"body": "You have reached your Codex usage limits for code reviews. You can see your limits in the [Codex usage dashboard](https://chatgpt.com/codex/cloud/settings/usage).\nTo continue using code reviews, add credits to your account and enable them for code reviews in your [settings](https://chatgpt.com/codex/cloud/settings/code-review).",
|
|
3
|
+
"created_at": "2026-09-30T01:50:30Z",
|
|
4
|
+
"id": 5902483341,
|
|
5
|
+
"user": {
|
|
6
|
+
"login": "chatgpt-codex-connector[bot]"
|
|
7
|
+
}
|
|
8
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"provenance": "Fields verified by the parent coordinator from the raw authenticated API. Body contains only the verified prefix. Repository identity is anonymized for the shared package; review fields are unchanged.",
|
|
3
|
+
"repository": "owner/repository",
|
|
4
|
+
"pull_request": 4743,
|
|
5
|
+
"review": {
|
|
6
|
+
"id": 5322799208,
|
|
7
|
+
"user": {
|
|
8
|
+
"login": "chatgpt-codex-connector[bot]"
|
|
9
|
+
},
|
|
10
|
+
"state": "COMMENTED",
|
|
11
|
+
"commit_id": "bed23717ab2ea352113c8f2058fbc959464bdf43",
|
|
12
|
+
"submitted_at": "2026-09-25T21:15:07Z",
|
|
13
|
+
"body": "Here are some automated review suggestions"
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Native guidance has a repository entry point and stays repository-independent."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_native_guidance_is_discoverable() -> None:
|
|
7
|
+
repository = Path(__file__).resolve().parents[4]
|
|
8
|
+
instructions = (repository / "AGENTS.md").read_text(encoding="utf-8")
|
|
9
|
+
assert "# Code Review Rules" in instructions
|
|
10
|
+
assert "docs/review/review.md" in instructions
|
|
11
|
+
guidance = (repository / "docs/review/review.md").read_text(encoding="utf-8")
|
|
12
|
+
assert "Current repository instructions override stale hosted copies." in guidance
|
|
13
|
+
assert "Completed review can contain findings." in guidance
|
|
14
|
+
assert "theme_db_id" not in guidance
|
|
15
|
+
assert "saved-grid" not in guidance
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
"""Refusal behavior for native review evidence whose success format is unknown."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
SCRIPTS_DIRECTORY = Path(__file__).resolve().parents[1]
|
|
12
|
+
if str(SCRIPTS_DIRECTORY) not in sys.path:
|
|
13
|
+
sys.path.insert(0, str(SCRIPTS_DIRECTORY))
|
|
14
|
+
|
|
15
|
+
import codex_review_observer as observer
|
|
16
|
+
import review_closure_github
|
|
17
|
+
from pr_verification.github_parsing import GitHubError
|
|
18
|
+
|
|
19
|
+
HEAD = "a" * 40
|
|
20
|
+
NEXT_HEAD = "b" * 40
|
|
21
|
+
CANDIDATE = observer.Candidate("owner/repository", 7, HEAD)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def observe(
|
|
25
|
+
all_pages: list[object], after_head: str = HEAD
|
|
26
|
+
) -> observer.ReviewObservation:
|
|
27
|
+
all_reads: list[object] = [
|
|
28
|
+
{"number": 7, "head": {"sha": HEAD}},
|
|
29
|
+
*all_pages,
|
|
30
|
+
{"number": 7, "head": {"sha": after_head}},
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
def read(url: str) -> object:
|
|
34
|
+
if "/issues/7/comments?" in url:
|
|
35
|
+
return []
|
|
36
|
+
assert url.startswith("https://api.github.com/repos/owner/repository/pulls/7")
|
|
37
|
+
return all_reads.pop(0)
|
|
38
|
+
|
|
39
|
+
return observer.observe_codex_review(CANDIDATE, read)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@pytest.mark.parametrize(
|
|
43
|
+
"body",
|
|
44
|
+
[
|
|
45
|
+
"No findings. Review completed successfully.",
|
|
46
|
+
"Fixed in the next push.",
|
|
47
|
+
"Review resolved.",
|
|
48
|
+
"Codex cloud task completed.",
|
|
49
|
+
"You've reached your Codex code review usage limits.",
|
|
50
|
+
],
|
|
51
|
+
)
|
|
52
|
+
def test_body_text_never_authorizes_merge(body: str) -> None:
|
|
53
|
+
review = {"id": 1, "commit_id": HEAD, "state": "APPROVED", "body": body}
|
|
54
|
+
observed = observe([[review]])
|
|
55
|
+
assert observed.reason is observer.HoldReason.NATIVE_FORMAT_UNVERIFIED
|
|
56
|
+
assert observed.evidence_ids == (1,)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_missing_review_holds() -> None:
|
|
60
|
+
assert observe([[]]).reason is observer.HoldReason.MISSING_REVIEW
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_stale_completion_holds() -> None:
|
|
64
|
+
review = {"id": 1, "commit_id": NEXT_HEAD, "state": "APPROVED"}
|
|
65
|
+
assert observe([[review]]).reason is observer.HoldReason.STALE_REVIEW
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_head_movement_invalidates_observation() -> None:
|
|
69
|
+
review = {"id": 1, "commit_id": HEAD, "state": "APPROVED"}
|
|
70
|
+
assert observe([[review]], NEXT_HEAD).reason is observer.HoldReason.HEAD_CHANGED
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@pytest.mark.parametrize(
|
|
74
|
+
"state", ["PENDING", "DISMISSED", "FAILED", "CANCELLED", "SKIPPED"]
|
|
75
|
+
)
|
|
76
|
+
def test_unsuccessful_state_holds(state: str) -> None:
|
|
77
|
+
review = {"id": 1, "commit_id": HEAD, "state": state}
|
|
78
|
+
assert observe([[review]]).reason is observer.HoldReason.NATIVE_FORMAT_UNVERIFIED
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_identical_duplicates_are_idempotent() -> None:
|
|
82
|
+
review = {"id": 1, "commit_id": HEAD, "body": "No findings"}
|
|
83
|
+
assert observe([[review, review]]).evidence_ids == (1,)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_conflicting_duplicate_cannot_pass() -> None:
|
|
87
|
+
first = {"id": 1, "commit_id": HEAD, "body": "No findings"}
|
|
88
|
+
later = {**first, "body": "A finding remains"}
|
|
89
|
+
assert observe([[first, later]]).reason is observer.HoldReason.CONFLICTING_EVIDENCE
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_delayed_success_cannot_override_newer_failure() -> None:
|
|
93
|
+
failed = {"id": 2, "commit_id": HEAD, "state": "FAILED"}
|
|
94
|
+
delayed = {"id": 1, "commit_id": HEAD, "state": "APPROVED"}
|
|
95
|
+
assert (
|
|
96
|
+
observe([[failed, delayed]]).reason
|
|
97
|
+
is observer.HoldReason.NATIVE_FORMAT_UNVERIFIED
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@pytest.mark.parametrize(
|
|
102
|
+
"failure", [TimeoutError(), GitHubError("quota unavailable"), OSError()]
|
|
103
|
+
)
|
|
104
|
+
def test_transport_failure_holds(failure: Exception) -> None:
|
|
105
|
+
def read(url: str) -> object:
|
|
106
|
+
raise failure
|
|
107
|
+
|
|
108
|
+
observed = observer.observe_codex_review(CANDIDATE, read)
|
|
109
|
+
assert observed.reason in (
|
|
110
|
+
observer.HoldReason.READ_TIMEOUT,
|
|
111
|
+
observer.HoldReason.READ_UNAVAILABLE,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@pytest.mark.parametrize("page", [None, {}, [None], [{"id": "1"}]])
|
|
116
|
+
def test_malformed_review_collection_holds(page: object) -> None:
|
|
117
|
+
assert observe([page]).reason is observer.HoldReason.INCOMPLETE_READ
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_incomplete_pagination_holds(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
121
|
+
monkeypatch.setattr(observer, "MAX_PAGES", 1)
|
|
122
|
+
all_reviews = [{"id": each_id + 1, "commit_id": HEAD} for each_id in range(100)]
|
|
123
|
+
assert observe([all_reviews]).reason is observer.HoldReason.INCOMPLETE_READ
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@pytest.mark.parametrize("head", ["abc123", "A" * 40, "a" * 39])
|
|
127
|
+
def test_candidate_requires_full_head(head: str) -> None:
|
|
128
|
+
with pytest.raises(ValueError):
|
|
129
|
+
observer.Candidate("owner/repository", 7, head)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_wrong_pull_request_is_a_hold() -> None:
|
|
133
|
+
def read(url: str) -> object:
|
|
134
|
+
return {"number": 8, "head": {"sha": HEAD}}
|
|
135
|
+
|
|
136
|
+
assert (
|
|
137
|
+
observer.observe_codex_review(CANDIDATE, read).reason
|
|
138
|
+
is observer.HoldReason.HEAD_CHANGED
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_cli_returns_nonzero_with_plausible_success(
|
|
143
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
144
|
+
) -> None:
|
|
145
|
+
monkeypatch.setattr(observer, "github_token", lambda: "test-token")
|
|
146
|
+
all_reads = iter(
|
|
147
|
+
[
|
|
148
|
+
{"number": 7, "head": {"sha": HEAD}},
|
|
149
|
+
[{"id": 1, "commit_id": HEAD, "state": "APPROVED"}],
|
|
150
|
+
[],
|
|
151
|
+
{"number": 7, "head": {"sha": HEAD}},
|
|
152
|
+
]
|
|
153
|
+
)
|
|
154
|
+
monkeypatch.setattr(observer, "request_json", lambda *arguments: next(all_reads))
|
|
155
|
+
assert observer.main(["owner/repository", "7", HEAD]) == 1
|
|
156
|
+
assert '"admission": "hold"' in capsys.readouterr().out
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_parent_verified_submitted_findings_hold() -> None:
|
|
160
|
+
fixture_path = Path(__file__).parent / "fixtures/codex-submitted-findings.json"
|
|
161
|
+
captured = json.loads(fixture_path.read_text(encoding="utf-8"))
|
|
162
|
+
review = captured["review"]
|
|
163
|
+
candidate = observer.Candidate(
|
|
164
|
+
captured["repository"], captured["pull_request"], review["commit_id"]
|
|
165
|
+
)
|
|
166
|
+
all_reads = iter(
|
|
167
|
+
[
|
|
168
|
+
{"number": candidate.pull_request, "head": {"sha": candidate.head_sha}},
|
|
169
|
+
[review],
|
|
170
|
+
[],
|
|
171
|
+
{"number": candidate.pull_request, "head": {"sha": candidate.head_sha}},
|
|
172
|
+
]
|
|
173
|
+
)
|
|
174
|
+
observed = observer.observe_codex_review(candidate, lambda url: next(all_reads))
|
|
175
|
+
assert observed.reason is observer.HoldReason.REVIEW_FINDINGS
|
|
176
|
+
assert observed.evidence_ids == (5322799208,)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def test_native_pilot_heading_preserves_findings_hold() -> None:
|
|
180
|
+
captured = json.loads(
|
|
181
|
+
(Path(__file__).parent / "fixtures/codex-pilot-findings.json").read_text(
|
|
182
|
+
encoding="utf-8-sig"
|
|
183
|
+
)
|
|
184
|
+
)
|
|
185
|
+
review = captured["review"]
|
|
186
|
+
candidate = observer.Candidate(
|
|
187
|
+
captured["repository"], captured["pull_request"], review["commit_id"]
|
|
188
|
+
)
|
|
189
|
+
all_reads = iter(
|
|
190
|
+
[
|
|
191
|
+
{"number": candidate.pull_request, "head": {"sha": candidate.head_sha}},
|
|
192
|
+
[review],
|
|
193
|
+
[],
|
|
194
|
+
{"number": candidate.pull_request, "head": {"sha": candidate.head_sha}},
|
|
195
|
+
]
|
|
196
|
+
)
|
|
197
|
+
observed = observer.observe_codex_review(candidate, lambda url: next(all_reads))
|
|
198
|
+
assert observed.reason is observer.HoldReason.REVIEW_FINDINGS
|
|
199
|
+
assert observed.evidence_ids == (5360553725,)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def test_cli_production_transport_preserves_timeout(
|
|
203
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
204
|
+
) -> None:
|
|
205
|
+
def timed_out(*args: object, **kwargs: object) -> object:
|
|
206
|
+
raise TimeoutError("socket read timed out")
|
|
207
|
+
|
|
208
|
+
monkeypatch.setattr(observer, "github_token", lambda: "test-token")
|
|
209
|
+
monkeypatch.setattr(review_closure_github.urllib.request, "urlopen", timed_out)
|
|
210
|
+
assert observer.main(["owner/repository", "7", HEAD]) == 2
|
|
211
|
+
assert json.loads(capsys.readouterr().out)["reason"] == "read_timeout"
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def test_findings_text_from_wrong_actor_stays_unsupported() -> None:
|
|
215
|
+
review = {
|
|
216
|
+
"id": 1,
|
|
217
|
+
"commit_id": HEAD,
|
|
218
|
+
"state": "COMMENTED",
|
|
219
|
+
"submitted_at": "2026-09-25T21:15:07Z",
|
|
220
|
+
"user": {"login": "unrelated-reviewer"},
|
|
221
|
+
"body": observer.CODEX_FINDINGS_PREFIX,
|
|
222
|
+
}
|
|
223
|
+
assert observe([[review]]).reason is observer.HoldReason.NATIVE_FORMAT_UNVERIFIED
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@pytest.mark.parametrize("actor", [observer.CODEX_REVIEWER_LOGIN, "other-user"])
|
|
227
|
+
@pytest.mark.parametrize(
|
|
228
|
+
"fixture_name", ["codex-quota-comment.json", "codex-quota-credits-comment.json"]
|
|
229
|
+
)
|
|
230
|
+
def test_authentic_quota_comment_is_diagnostic_only(
|
|
231
|
+
actor: str, fixture_name: str
|
|
232
|
+
) -> None:
|
|
233
|
+
comment = json.loads(
|
|
234
|
+
(Path(__file__).parent / "fixtures" / fixture_name).read_text(
|
|
235
|
+
encoding="utf-8-sig"
|
|
236
|
+
)
|
|
237
|
+
)
|
|
238
|
+
comment["user"]["login"] = actor
|
|
239
|
+
|
|
240
|
+
def read(url: str) -> object:
|
|
241
|
+
if "/reviews?" in url:
|
|
242
|
+
return []
|
|
243
|
+
if "/comments?" in url:
|
|
244
|
+
return [comment]
|
|
245
|
+
return {"number": 7, "head": {"sha": HEAD}}
|
|
246
|
+
|
|
247
|
+
observed = observer.observe_codex_review(CANDIDATE, read)
|
|
248
|
+
assert observed.reason is (
|
|
249
|
+
observer.HoldReason.QUOTA_NOTICE
|
|
250
|
+
if actor == observer.CODEX_REVIEWER_LOGIN
|
|
251
|
+
else observer.HoldReason.MISSING_REVIEW
|
|
252
|
+
)
|
|
253
|
+
assert observed.evidence_ids == ()
|
|
254
|
+
assert observed.quota_notice_ids == (
|
|
255
|
+
(comment["id"],) if actor == observer.CODEX_REVIEWER_LOGIN else ()
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
@pytest.mark.parametrize(
|
|
260
|
+
"response,reason",
|
|
261
|
+
[
|
|
262
|
+
({}, observer.HoldReason.INCOMPLETE_READ),
|
|
263
|
+
([None], observer.HoldReason.INCOMPLETE_READ),
|
|
264
|
+
(TimeoutError(), observer.HoldReason.READ_TIMEOUT),
|
|
265
|
+
(GitHubError("comments unavailable"), observer.HoldReason.READ_UNAVAILABLE),
|
|
266
|
+
(OSError(), observer.HoldReason.READ_UNAVAILABLE),
|
|
267
|
+
],
|
|
268
|
+
)
|
|
269
|
+
@pytest.mark.parametrize("has_current_review", [False, True])
|
|
270
|
+
def test_quota_comment_read_failure_holds(
|
|
271
|
+
response: object, reason: observer.HoldReason, has_current_review: bool
|
|
272
|
+
) -> None:
|
|
273
|
+
def read(url: str) -> object:
|
|
274
|
+
if "/reviews?" in url:
|
|
275
|
+
return [{"id": 1, "commit_id": HEAD}] if has_current_review else []
|
|
276
|
+
if "/comments?" in url:
|
|
277
|
+
if isinstance(response, Exception):
|
|
278
|
+
raise response
|
|
279
|
+
return response
|
|
280
|
+
return {"number": 7, "head": {"sha": HEAD}}
|
|
281
|
+
|
|
282
|
+
observed = observer.observe_codex_review(CANDIDATE, read)
|
|
283
|
+
assert observed.reason is reason
|
|
284
|
+
assert observed.evidence_ids == ()
|
|
285
|
+
assert observed.quota_notice_ids == ()
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
@pytest.mark.parametrize("has_findings", [False, True])
|
|
289
|
+
@pytest.mark.parametrize("head_changes", [False, True])
|
|
290
|
+
def test_current_review_preserves_historical_quota_notices(
|
|
291
|
+
has_findings: bool, head_changes: bool
|
|
292
|
+
) -> None:
|
|
293
|
+
captured = json.loads(
|
|
294
|
+
(Path(__file__).parent / "fixtures/codex-pilot-findings.json").read_text(
|
|
295
|
+
encoding="utf-8-sig"
|
|
296
|
+
)
|
|
297
|
+
)
|
|
298
|
+
review = captured["review"]
|
|
299
|
+
if not has_findings:
|
|
300
|
+
review = {
|
|
301
|
+
"id": 42,
|
|
302
|
+
"commit_id": review["commit_id"],
|
|
303
|
+
"state": "APPROVED",
|
|
304
|
+
"user": {"login": "human-reviewer"},
|
|
305
|
+
}
|
|
306
|
+
candidate = observer.Candidate(
|
|
307
|
+
captured["repository"], captured["pull_request"], review["commit_id"]
|
|
308
|
+
)
|
|
309
|
+
comment = json.loads(
|
|
310
|
+
(Path(__file__).parent / "fixtures/codex-quota-credits-comment.json").read_text(
|
|
311
|
+
encoding="utf-8-sig"
|
|
312
|
+
)
|
|
313
|
+
)
|
|
314
|
+
all_heads = iter(
|
|
315
|
+
[candidate.head_sha, NEXT_HEAD if head_changes else candidate.head_sha]
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
def read(url: str) -> object:
|
|
319
|
+
if "/reviews?" in url:
|
|
320
|
+
return [review]
|
|
321
|
+
if "/comments?" in url:
|
|
322
|
+
return [comment]
|
|
323
|
+
return {"number": candidate.pull_request, "head": {"sha": next(all_heads)}}
|
|
324
|
+
|
|
325
|
+
observed = observer.observe_codex_review(candidate, read)
|
|
326
|
+
if head_changes:
|
|
327
|
+
assert observed.reason is observer.HoldReason.HEAD_CHANGED
|
|
328
|
+
assert observed.evidence_ids == ()
|
|
329
|
+
assert observed.quota_notice_ids == ()
|
|
330
|
+
else:
|
|
331
|
+
assert observed.reason is (
|
|
332
|
+
observer.HoldReason.REVIEW_FINDINGS
|
|
333
|
+
if has_findings
|
|
334
|
+
else observer.HoldReason.NATIVE_FORMAT_UNVERIFIED
|
|
335
|
+
)
|
|
336
|
+
assert observed.evidence_ids == (review["id"],)
|
|
337
|
+
assert observed.quota_notice_ids == (5902483341,)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def test_quota_after_stale_review_remains_diagnostic_hold() -> None:
|
|
341
|
+
comment = json.loads(
|
|
342
|
+
(Path(__file__).parent / "fixtures/codex-quota-credits-comment.json").read_text(
|
|
343
|
+
encoding="utf-8-sig"
|
|
344
|
+
)
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
def read(url: str) -> object:
|
|
348
|
+
if "/reviews?" in url:
|
|
349
|
+
return [{"id": 1, "commit_id": NEXT_HEAD}]
|
|
350
|
+
if "/comments?" in url:
|
|
351
|
+
return [comment]
|
|
352
|
+
return {"number": 7, "head": {"sha": HEAD}}
|
|
353
|
+
|
|
354
|
+
observed = observer.observe_codex_review(CANDIDATE, read)
|
|
355
|
+
assert observed.reason is observer.HoldReason.QUOTA_NOTICE
|
|
356
|
+
assert observed.evidence_ids == ()
|
|
357
|
+
assert observed.quota_notice_ids == (5902483341,)
|