agentic-devtools 0.2.331__py3-none-any.whl → 0.2.332__py3-none-any.whl

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.
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.2.331'
22
- __version_tuple__ = version_tuple = (0, 2, 331)
21
+ __version__ = version = '0.2.332'
22
+ __version_tuple__ = version_tuple = (0, 2, 332)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -59,6 +59,7 @@ from agentic_devtools.cli.ci.resolution.tiers.outdated import OutdatedTier
59
59
  from agentic_devtools.cli.ci.resolution.tiers.sdk_evaluation import SdkEvaluationTier
60
60
  from agentic_devtools.cli.ci.retry import RetryableError, retry_with_backoff
61
61
  from agentic_devtools.cli.git.commit_template import _derive_issue_link_from_key, resolve_commit_message_from_template
62
+ from agentic_devtools.cli.github.ccr_review_format import extract_suppressed_comment_entries
62
63
  from agentic_devtools.cli.subprocess_utils import run_safe
63
64
  from agentic_devtools.config import load_platform_config
64
65
  from agentic_devtools.state import get_state_dir
@@ -272,35 +273,19 @@ query($owner: String!, $name: String!, $endCursor: String!) {
272
273
  }
273
274
  """
274
275
 
275
- # Regex to extract the <details> block containing suppressed comments.
276
- # Uses DOTALL so `.*?` spans newlines.
277
- _SUPPRESSED_DETAILS_RE = re.compile(
278
- r"<details>\s*<summary>[^<]*suppressed due to low confidence[^<]*</summary>(.*?)</details>",
279
- re.DOTALL | re.IGNORECASE,
280
- )
281
-
282
- # Regex to extract individual entries within the suppressed block.
283
- # Each entry starts with a bold or code-formatted file path, followed by body text.
284
- # Matches patterns like:
285
- # **path/to/file.py**: body text
286
- # `path/to/file.py`: body text
287
- # **`path/to/file.py`**: body text
288
- _SUPPRESSED_ENTRY_RE = re.compile(
289
- r"(?:\*\*`?|`)" # opening bold/code marker
290
- r"([^`*\n]+?)" # file path (captured)
291
- r"(?:`?\*\*|`)" # closing bold/code marker
292
- r"\s*:?\s*" # optional colon separator
293
- r"(.*?)(?=\n(?:\*\*`?|`)[^`*\n]+?(?:`?\*\*|`)\s*:?|\Z)", # body (captured, up to next entry or end)
294
- re.DOTALL,
295
- )
296
-
297
276
 
277
+ # Suppressed-comment recovery from a Copilot review body.
298
278
  def _parse_suppressed_from_review_body(review_body: str) -> list[ReviewCommentInfo]:
299
- """Parse suppressed comments from a Copilot review body HTML.
279
+ """Parse suppressed comments from a Copilot review body.
300
280
 
301
- Extracts comments from the ``<details>`` block with summary containing
302
- "suppressed due to low confidence". Each entry is expected to have a bold
303
- or code-formatted file path followed by comment body text.
281
+ Delegates block/entry extraction to the shared CCR parser
282
+ (:func:`agentic_devtools.cli.github.ccr_review_format.extract_suppressed_comment_entries`),
283
+ which understands **both** the legacy ``<details><summary>… suppressed …</summary>``
284
+ format and the new CCR private-preview format (a
285
+ ``### Comments suppressed due to low confidence (N)`` heading inside a
286
+ ``<details><summary>Review details</summary>`` block). Each recovered
287
+ ``(path, body)`` pair is wrapped as a ``ReviewCommentInfo`` with
288
+ ``is_suppressed=True`` and a unique negative sentinel ID.
304
289
 
305
290
  Args:
306
291
  review_body: Full review body text (may contain HTML).
@@ -309,29 +294,9 @@ def _parse_suppressed_from_review_body(review_body: str) -> list[ReviewCommentIn
309
294
  List of ``ReviewCommentInfo`` with ``is_suppressed=True`` and negative
310
295
  sentinel IDs. Returns empty list if no suppressed block is found.
311
296
  """
312
- if not review_body:
313
- return []
314
-
315
- match = _SUPPRESSED_DETAILS_RE.search(review_body)
316
- if not match:
317
- return []
318
-
319
- block_content = match.group(1).strip()
320
- if not block_content:
321
- return []
322
-
323
297
  entries: list[ReviewCommentInfo] = []
324
298
  sentinel_id = -1
325
-
326
- for entry_match in _SUPPRESSED_ENTRY_RE.finditer(block_content):
327
- path = entry_match.group(1).strip()
328
- body = entry_match.group(2).strip()
329
-
330
- if not path:
331
- path = "(unknown file)"
332
- if not body:
333
- continue
334
-
299
+ for path, body in extract_suppressed_comment_entries(review_body):
335
300
  entries.append(
336
301
  ReviewCommentInfo(
337
302
  id=sentinel_id,
@@ -343,24 +308,6 @@ def _parse_suppressed_from_review_body(review_body: str) -> list[ReviewCommentIn
343
308
  )
344
309
  sentinel_id -= 1
345
310
 
346
- # If we found a details block but no structured entries, try a line-based
347
- # fallback: treat each non-empty line as a standalone comment.
348
- if not entries:
349
- for line in block_content.splitlines():
350
- line = line.strip()
351
- if not line:
352
- continue
353
- entries.append(
354
- ReviewCommentInfo(
355
- id=sentinel_id,
356
- path="(unknown file)",
357
- body=line,
358
- html_url="",
359
- is_suppressed=True,
360
- )
361
- )
362
- sentinel_id -= 1
363
-
364
311
  return entries
365
312
 
366
313
 
@@ -11,18 +11,48 @@ from agentic_devtools.cli.ci.guards import (
11
11
  )
12
12
  from agentic_devtools.cli.ci.models import COPILOT_LOGINS, ReviewInfo
13
13
  from agentic_devtools.cli.ci.pipeline.exclusion import ExclusionContext
14
+ from agentic_devtools.cli.ci.pipeline.gate_verdict import (
15
+ REASON_HAS_COMMENTS,
16
+ REASON_NEW_CCR_NOT_APPROVED,
17
+ REASON_SUPPRESSED_COMMENTS,
18
+ )
14
19
  from agentic_devtools.cli.ci.pipeline.models import ActionDecision, ActionResult
15
20
  from agentic_devtools.cli.ci.pipeline.snapshot import DerivedState, PRStateSnapshot
16
21
  from agentic_devtools.cli.ci.provider import CIPlatformProvider
17
22
 
18
23
  logger = logging.getLogger(__name__)
19
24
 
25
+ #: Gate-verdict reasons that mean the HEAD Copilot review carries actionable
26
+ #: feedback in its *body* (posted comments, suppressed/low-confidence comments,
27
+ #: or a new-CCR-format "Not ready to approve" verdict). Freshness reasons
28
+ #: (awaiting-fresh, content-changed) are deliberately excluded — they call for a
29
+ #: *new* review, not a repair dispatch.
30
+ _CONTENT_BLOCKING_GATE_REASONS: frozenset[str] = frozenset(
31
+ {
32
+ REASON_HAS_COMMENTS,
33
+ REASON_SUPPRESSED_COMMENTS,
34
+ REASON_NEW_CCR_NOT_APPROVED,
35
+ }
36
+ )
37
+
20
38
 
21
39
  def _is_copilot_review_actionable(snapshot: PRStateSnapshot) -> bool:
22
40
  """Return True if the Copilot review on HEAD is actionable.
23
41
 
24
- Actionable means CHANGES_REQUESTED, or COMMENTED with inline comments.
25
- Unknown inline counts fail closed and are treated as actionable.
42
+ Actionable means:
43
+
44
+ * ``CHANGES_REQUESTED`` on HEAD, or
45
+ * ``COMMENTED`` on HEAD with inline comments (unknown inline counts fail
46
+ closed and are treated as actionable), or
47
+ * ``COMMENTED`` on HEAD with **zero** counted inline comments but a
48
+ content-blocking gate verdict for that same review.
49
+
50
+ The third branch closes a new-CCR-format gap: the private-preview format can
51
+ block the gate purely from the review body (a "Not ready to approve" verdict
52
+ heading, or body-only suppressed comments) while exposing no inline comments
53
+ through the API. Without it, such a PR is blocked by the gate yet never
54
+ triggers repair — a permanent stall. Freshness reasons are excluded because
55
+ they require a *new* review, not a repair.
26
56
  """
27
57
  if snapshot.review_state == "CHANGES_REQUESTED" and snapshot.copilot_review_id > 0:
28
58
  return True
@@ -32,6 +62,16 @@ def _is_copilot_review_actionable(snapshot: PRStateSnapshot) -> bool:
32
62
  and snapshot.copilot_review_inline_count != 0
33
63
  ):
34
64
  return True
65
+ verdict = snapshot.copilot_gate_verdict
66
+ if (
67
+ snapshot.review_state == "COMMENTED"
68
+ and snapshot.copilot_review_id > 0
69
+ and verdict is not None
70
+ and not verdict.passed
71
+ and verdict.reason in _CONTENT_BLOCKING_GATE_REASONS
72
+ and verdict.review_id == snapshot.copilot_review_id
73
+ ):
74
+ return True
35
75
  return False
36
76
 
37
77
 
@@ -21,6 +21,12 @@ from dataclasses import dataclass
21
21
  from typing import TYPE_CHECKING
22
22
 
23
23
  from agentic_devtools.cli.ci.models import COPILOT_LOGINS, ReviewInfo
24
+ from agentic_devtools.cli.github.ccr_review_format import (
25
+ VERDICT_NOT_APPROVE,
26
+ parse_reported_comment_count,
27
+ parse_suppressed_count,
28
+ parse_verdict,
29
+ )
24
30
 
25
31
  if TYPE_CHECKING:
26
32
  from agentic_devtools.cli.ci.provider import CIPlatformProvider
@@ -51,6 +57,9 @@ REASON_HAS_COMMENTS = "copilot_review_has_comments"
51
57
  #: Copilot's review body reports suppressed/low-confidence comments > 0.
52
58
  REASON_SUPPRESSED_COMMENTS = "copilot_review_suppressed_comments"
53
59
 
60
+ #: New CCR format review body has a "Not ready to approve" verdict heading.
61
+ REASON_NEW_CCR_NOT_APPROVED = "copilot_review_new_format_not_approved"
62
+
54
63
  #: PR diff content changed since the prior-commit review.
55
64
  REASON_CONTENT_CHANGED = "copilot_review_content_changed_since_review"
56
65
 
@@ -131,40 +140,6 @@ def _select_latest_prior_review(reviews: list[ReviewInfo], head_sha: str) -> Rev
131
140
  return max(candidates, key=lambda r: (r.submitted_at, r.id))
132
141
 
133
142
 
134
- def _parse_body_comment_count(body: str) -> int | None:
135
- """Parse Copilot's self-reported comment count from *body*.
136
-
137
- Matches:
138
- - ``"generated no comments"``
139
- - ``"generated no new comments"`` (re-review phrasing)
140
- - ``"generated N comment(s)"``
141
-
142
- Returns:
143
- An integer count, or ``None`` when the body format is unrecognised.
144
- """
145
- if re.search(r"generated no( new)? comments", body, re.IGNORECASE):
146
- return 0
147
- m = re.search(r"generated (\d+) comment", body, re.IGNORECASE)
148
- if m:
149
- return int(m.group(1))
150
- return None
151
-
152
-
153
- def _parse_suppressed_count(body: str) -> int:
154
- """Parse the suppressed / low-confidence comment count from *body*.
155
-
156
- Matches ``Suppressed (N)`` and ``Low confidence (N)`` as reported by
157
- Copilot in the review summary text.
158
- """
159
- m = re.search(r"[Ss]uppressed[^.(]*\((\d+)\)", body)
160
- if m:
161
- return int(m.group(1))
162
- m = re.search(r"[Ll]ow confidence[^.(]*\((\d+)\)", body)
163
- if m:
164
- return int(m.group(1))
165
- return 0
166
-
167
-
168
143
  def _evaluate_synthetic_review(
169
144
  review: ReviewInfo,
170
145
  provider: CIPlatformProvider,
@@ -271,14 +246,33 @@ def _evaluate_standard_review(
271
246
  ) -> CopilotGateVerdict:
272
247
  """Evaluate a standard (non-synthetic) Copilot review.
273
248
 
274
- Parses ``"generated N comments"`` and ``"Suppressed (N)"`` from the review
275
- body. When the body format is unrecognised, falls back to the API inline
276
- comment count (fail-closed).
249
+ Handles both the legacy CCR body format (``"generated N comments"``,
250
+ ``"Suppressed (N)"``) and the new-style CCR private-preview format with a
251
+ ``### <emoji> <verdict>`` heading and ``**Comments generated:** N new``
252
+ metrics footer. When the body format is unrecognised, falls back to the
253
+ API inline comment count (fail-closed).
277
254
  """
278
255
  body = review.body
279
256
 
280
- body_comment_count = _parse_body_comment_count(body)
281
- suppressed_count = _parse_suppressed_count(body)
257
+ # --- New CCR format: check verdict heading first ---
258
+ # "### 🟡 Not ready to approve" → block immediately regardless of comment counts.
259
+ # "### ✅ Ready to approve" → proceed with comment-count checks below.
260
+ new_ccr_verdict = parse_verdict(body)
261
+ if new_ccr_verdict == VERDICT_NOT_APPROVE:
262
+ suppressed_count = parse_suppressed_count(body)
263
+ detail_parts = ["Copilot review heading indicates 'Not ready to approve'"]
264
+ if suppressed_count > 0:
265
+ detail_parts.append(f"{suppressed_count} suppressed/low-confidence comment(s)")
266
+ return CopilotGateVerdict(
267
+ passed=False,
268
+ reason=REASON_NEW_CCR_NOT_APPROVED,
269
+ review_id=review.id,
270
+ suppressed_count=suppressed_count,
271
+ details="; ".join(detail_parts),
272
+ )
273
+
274
+ body_comment_count = parse_reported_comment_count(body)
275
+ suppressed_count = parse_suppressed_count(body)
282
276
 
283
277
  if body_comment_count is None:
284
278
  # Body format unrecognised — fall back to the API inline count.
@@ -0,0 +1,279 @@
1
+ """Canonical parsers for Copilot Code Review (CCR) review bodies.
2
+
3
+ This module is the **single source of truth** for extracting structured
4
+ information from a Copilot Code Review body. It understands two body formats:
5
+
6
+ **Legacy format** — prose summary with:
7
+
8
+ - ``"generated N comment(s)"`` / ``"generated no comments"`` phrasing.
9
+ - ``Suppressed (N)`` / ``Low confidence (N)`` counts.
10
+ - A ``<details><summary>… suppressed due to low confidence …</summary>…</details>``
11
+ block whose entries are ``**path**: body`` / ``` `path`: body ``` lines.
12
+
13
+ **New CCR private-preview format** — a ``### <emoji> <verdict>`` heading
14
+ (``### 🟡 Not ready to approve`` / ``### ✅ Ready to approve``) followed by a
15
+ ``<details><summary>Review details</summary>`` block containing:
16
+
17
+ - A ``### Comments suppressed due to low confidence (N)`` heading, whose entries
18
+ are ``**path:line**`` on one line followed by a ``* body`` bullet (and, often,
19
+ a fenced code block).
20
+ - A metrics footer (``- **Files reviewed:** …``, ``- **Comments generated:** N``,
21
+ ``- **Review effort level:** …``).
22
+
23
+ Centralising this logic keeps every consumer in the AI PR loop consistent:
24
+
25
+ - :mod:`agentic_devtools.cli.ci.pipeline.gate_verdict` (approval/merge gate)
26
+ - :mod:`agentic_devtools.cli.ci.github_provider` (suppressed-comment recovery
27
+ for repair dispatch)
28
+ - :mod:`agentic_devtools.cli.github.copilot_review_status` (poll-ready
29
+ review-status classification)
30
+
31
+ Public API
32
+ ----------
33
+ - :data:`VERDICT_NOT_APPROVE`, :data:`VERDICT_APPROVE` — verdict constants.
34
+ - :data:`UNKNOWN_FILE` — placeholder path for unattributed suppressed comments.
35
+ - :func:`parse_verdict` — new-format ``### <verdict>`` heading → verdict.
36
+ - :func:`parse_reported_comment_count` — self-reported posted-comment count.
37
+ - :func:`parse_suppressed_count` — self-reported suppressed/low-confidence count.
38
+ - :func:`extract_suppressed_comment_entries` — ``(path, body)`` pairs for every
39
+ recoverable suppressed comment (both formats).
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import re
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # Public constants
48
+ # ---------------------------------------------------------------------------
49
+
50
+ #: Verdict returned when the new-format heading blocks approval.
51
+ VERDICT_NOT_APPROVE = "not_approve"
52
+
53
+ #: Verdict returned when the new-format heading approves.
54
+ VERDICT_APPROVE = "approve"
55
+
56
+ #: Placeholder path used when a suppressed comment cannot be attributed to a file.
57
+ UNKNOWN_FILE = "(unknown file)"
58
+
59
+ # ---------------------------------------------------------------------------
60
+ # Verdict heading
61
+ # ---------------------------------------------------------------------------
62
+
63
+ #: Matches any ``###``-level (or deeper) heading line; the heading text is captured.
64
+ _HEADING_RE = re.compile(r"^#{3,}\s+(.+?)\s*$", re.MULTILINE)
65
+
66
+
67
+ def parse_verdict(body: str) -> str | None:
68
+ """Parse the review verdict from a new-style CCR heading.
69
+
70
+ Scans for ``###``-level headings introduced by the new CCR private-preview
71
+ format (e.g. ``### 🟡 Not ready to approve``, ``### ✅ Ready to approve``)
72
+ and returns the verdict from the first heading that expresses one.
73
+
74
+ ``"not ready to approve"`` is checked before ``"ready to approve"`` so the
75
+ substring match cannot mis-classify a blocking heading as approving.
76
+
77
+ Args:
78
+ body: Full review body text.
79
+
80
+ Returns:
81
+ :data:`VERDICT_NOT_APPROVE` when a blocking verdict heading is found,
82
+ :data:`VERDICT_APPROVE` when an approving verdict heading is found, or
83
+ ``None`` when no recognised CCR verdict heading is present (in which
84
+ case callers should fall back to their existing logic).
85
+ """
86
+ if not body:
87
+ return None
88
+ for match in _HEADING_RE.finditer(body):
89
+ heading_text = match.group(1).strip().lower()
90
+ if "not ready to approve" in heading_text:
91
+ return VERDICT_NOT_APPROVE
92
+ if "ready to approve" in heading_text:
93
+ return VERDICT_APPROVE
94
+ return None
95
+
96
+
97
+ # ---------------------------------------------------------------------------
98
+ # Reported comment count
99
+ # ---------------------------------------------------------------------------
100
+
101
+ #: Legacy "generated no [new] comments" phrasing → count 0.
102
+ _GENERATED_NONE_RE = re.compile(r"generated no( new)? comments", re.IGNORECASE)
103
+
104
+ #: Legacy "generated N comment(s)" phrasing → count N.
105
+ _GENERATED_N_RE = re.compile(r"generated (\d+) comment", re.IGNORECASE)
106
+
107
+ #: New CCR metrics-footer "**Comments generated:** N [new]" → count N.
108
+ #: The trailing "new" qualifier is optional — some reviews report a bare count
109
+ #: (``**Comments generated:** 4``) while re-reviews report ``0 new``.
110
+ _METRICS_GENERATED_RE = re.compile(
111
+ r"Comments generated[^:\n]*:\s*\*{0,2}\s*(\d+)(?:\s+new)?\b",
112
+ re.IGNORECASE,
113
+ )
114
+
115
+
116
+ def parse_reported_comment_count(body: str) -> int | None:
117
+ """Parse Copilot's self-reported posted-comment count from *body*.
118
+
119
+ Matches, in priority order:
120
+
121
+ - ``"generated no comments"`` / ``"generated no new comments"`` → ``0``
122
+ - ``"generated N comment(s)"`` → ``N``
123
+ - ``"**Comments generated:** N [new]"`` (new CCR metrics footer) → ``N``
124
+
125
+ Args:
126
+ body: Full review body text.
127
+
128
+ Returns:
129
+ An integer count, or ``None`` when no recognised count pattern is
130
+ present (callers fail closed on ``None``).
131
+ """
132
+ if not body:
133
+ return None
134
+ if _GENERATED_NONE_RE.search(body):
135
+ return 0
136
+ match = _GENERATED_N_RE.search(body)
137
+ if match:
138
+ return int(match.group(1))
139
+ match = _METRICS_GENERATED_RE.search(body)
140
+ if match:
141
+ return int(match.group(1))
142
+ return None
143
+
144
+
145
+ # ---------------------------------------------------------------------------
146
+ # Suppressed / low-confidence count
147
+ # ---------------------------------------------------------------------------
148
+
149
+ #: "… suppressed … (N)" — matches both the legacy ``<summary>`` text and the
150
+ #: new-format ``### Comments suppressed due to low confidence (N)`` heading.
151
+ _SUPPRESSED_COUNT_RE = re.compile(r"[Ss]uppressed[^.(]*\((\d+)\)")
152
+
153
+ #: "… low confidence … (N)" fallback phrasing.
154
+ _LOW_CONFIDENCE_COUNT_RE = re.compile(r"[Ll]ow confidence[^.(]*\((\d+)\)")
155
+
156
+
157
+ def parse_suppressed_count(body: str) -> int:
158
+ """Parse the suppressed / low-confidence comment count from *body*.
159
+
160
+ Matches ``Suppressed (N)`` (including the new format's
161
+ ``### Comments suppressed due to low confidence (N)`` heading) and, as a
162
+ fallback, ``Low confidence (N)``.
163
+
164
+ Args:
165
+ body: Full review body text.
166
+
167
+ Returns:
168
+ The parsed count, or ``0`` when no suppressed-count pattern is present.
169
+ """
170
+ if not body:
171
+ return 0
172
+ match = _SUPPRESSED_COUNT_RE.search(body)
173
+ if match:
174
+ return int(match.group(1))
175
+ match = _LOW_CONFIDENCE_COUNT_RE.search(body)
176
+ if match:
177
+ return int(match.group(1))
178
+ return 0
179
+
180
+
181
+ # ---------------------------------------------------------------------------
182
+ # Suppressed comment entries
183
+ # ---------------------------------------------------------------------------
184
+
185
+ #: Legacy: the ``<details>`` block whose ``<summary>`` names the suppressed set.
186
+ _LEGACY_SUPPRESSED_BLOCK_RE = re.compile(
187
+ r"<details>\s*<summary>[^<]*suppressed due to low confidence[^<]*</summary>(.*?)</details>",
188
+ re.DOTALL | re.IGNORECASE,
189
+ )
190
+
191
+ #: New format: content after the ``### Comments suppressed …`` heading, bounded
192
+ #: before the next heading, the metrics footer, or the ``</details>`` close so the
193
+ #: footer lines (``- **Files reviewed:** …``) are never captured as entries.
194
+ _NEW_SUPPRESSED_BLOCK_RE = re.compile(
195
+ r"#{2,}\s+Comments?\s+suppressed\s+due\s+to\s+low\s+confidence[^\n]*\n"
196
+ r"(.*?)"
197
+ r"(?=\n\s*#{2,}\s" # next heading
198
+ r"|\n\s*[-*]\s+\*\*(?:Files reviewed|Comments generated|Review effort)" # metrics footer
199
+ r"|</details>" # end of the Review details block
200
+ r"|\Z)",
201
+ re.DOTALL | re.IGNORECASE,
202
+ )
203
+
204
+ #: A single suppressed entry: a bold/code file path followed by body text.
205
+ #: Matches ``**path**: body``, ``` `path`: body ```, ``**`path`**: body``, and the
206
+ #: new format's ``**path:line**`` header followed by a ``* body`` bullet.
207
+ _SUPPRESSED_ENTRY_RE = re.compile(
208
+ r"(?:\*\*`?|`)" # opening bold/code marker
209
+ r"([^`*\n]+?)" # file path (captured)
210
+ r"(?:`?\*\*|`)" # closing bold/code marker
211
+ r"\s*:?\s*" # optional colon separator
212
+ r"(.*?)(?=\n(?:\*\*`?|`)[^`*\n]+?(?:`?\*\*|`)\s*:?|\Z)", # body up to next entry or end
213
+ re.DOTALL,
214
+ )
215
+
216
+
217
+ def _find_suppressed_block(body: str) -> str | None:
218
+ """Return the suppressed-comment block content for either format, or ``None``.
219
+
220
+ Prefers the legacy ``<details>`` block; falls back to the new-format
221
+ ``### Comments suppressed …`` section. Returns ``None`` when neither is
222
+ present or the located block is empty.
223
+ """
224
+ legacy = _LEGACY_SUPPRESSED_BLOCK_RE.search(body)
225
+ if legacy:
226
+ return legacy.group(1).strip() or None
227
+ new = _NEW_SUPPRESSED_BLOCK_RE.search(body)
228
+ if new:
229
+ return new.group(1).strip() or None
230
+ return None
231
+
232
+
233
+ def extract_suppressed_comment_entries(body: str) -> list[tuple[str, str]]:
234
+ """Extract ``(path, body)`` pairs for suppressed comments in *body*.
235
+
236
+ Handles both the legacy ``<details>`` block and the new-format
237
+ ``### Comments suppressed …`` section. When a structured entry has no file
238
+ path, :data:`UNKNOWN_FILE` is used. When the block contains no structured
239
+ entries at all, each non-blank line becomes a standalone
240
+ ``(UNKNOWN_FILE, line)`` fallback comment.
241
+
242
+ Args:
243
+ body: Full review body text (may contain HTML/Markdown).
244
+
245
+ Returns:
246
+ A list of ``(path, comment_body)`` tuples in document order. Empty when
247
+ no suppressed block is found or the block yields no non-empty content.
248
+ """
249
+ if not body:
250
+ return []
251
+
252
+ block_content = _find_suppressed_block(body)
253
+ if not block_content:
254
+ return []
255
+
256
+ entries: list[tuple[str, str]] = []
257
+ for entry_match in _SUPPRESSED_ENTRY_RE.finditer(block_content):
258
+ path = entry_match.group(1).strip() or UNKNOWN_FILE
259
+ entry_body = entry_match.group(2).strip()
260
+ # The new CCR format renders each comment as a markdown bullet
261
+ # (``* comment``); strip a single leading bullet marker so the recovered
262
+ # comment text is clean. Legacy bodies carry no bullet, so this is a
263
+ # no-op for them.
264
+ if entry_body[:2] in ("* ", "- "):
265
+ entry_body = entry_body[2:].strip()
266
+ if not entry_body:
267
+ continue
268
+ entries.append((path, entry_body))
269
+
270
+ # If the block matched but produced no structured entries, treat each
271
+ # non-empty line as a standalone comment (fail-open recovery).
272
+ if not entries:
273
+ for line in block_content.splitlines():
274
+ stripped = line.strip()
275
+ if not stripped:
276
+ continue
277
+ entries.append((UNKNOWN_FILE, stripped))
278
+
279
+ return entries
@@ -3,8 +3,10 @@
3
3
  This module provides the ``agdt-gh-copilot-review-status`` CLI command that
4
4
  fetches reviews via the ``gh`` CLI REST API, filters to the Copilot reviewer
5
5
  bot on the current head commit, counts inline and suppressed (minimized)
6
- comments via GraphQL cursor pagination, classifies the review status, and
7
- returns structured JSON to stdout while writing state keys.
6
+ comments via GraphQL cursor pagination, additionally parses the review **body**
7
+ for the new CCR private-preview format (a "Not ready to approve" verdict and
8
+ body-only suppressed comments that are not GitHub-minimized), classifies the
9
+ review status, and returns structured JSON to stdout while writing state keys.
8
10
 
9
11
  Public API
10
12
  ----------
@@ -25,6 +27,12 @@ from typing import Any
25
27
 
26
28
  from ...state import get_value, set_value
27
29
  from ..subprocess_utils import run_safe
30
+ from .ccr_review_format import (
31
+ VERDICT_NOT_APPROVE,
32
+ parse_reported_comment_count,
33
+ parse_suppressed_count,
34
+ parse_verdict,
35
+ )
28
36
  from .repo_resolution import resolve_github_repo
29
37
 
30
38
  # ---------------------------------------------------------------------------
@@ -282,14 +290,42 @@ def _count_suppressed_comments(review_node_id: str) -> int:
282
290
  return suppressed_count
283
291
 
284
292
 
285
- def _classify_review_status(review_state: str, inline_count: int, suppressed_count: int) -> tuple[str, str]:
293
+ def _classify_review_status(
294
+ review_state: str,
295
+ inline_count: int,
296
+ suppressed_count: int,
297
+ *,
298
+ not_approved: bool = False,
299
+ body_comment_count: int | None = None,
300
+ ) -> tuple[str, str]:
286
301
  """Classify the overall Copilot review status.
287
302
 
303
+ Args:
304
+ review_state: The review's ``state`` (``APPROVED`` / ``COMMENTED`` /
305
+ ``CHANGES_REQUESTED`` / …). The new CCR private-preview reviews are
306
+ always ``COMMENTED``, so the ``not_approved`` body verdict — not the
307
+ review state — is what makes them blocking.
308
+ inline_count: Copilot-authored inline review comments (REST).
309
+ suppressed_count: Suppressed/low-confidence comments, merged across
310
+ GraphQL-minimized comments and the review body so the new format's
311
+ body-only suppressed comments are counted.
312
+ not_approved: ``True`` when the review body carries a new-format
313
+ "Not ready to approve" verdict heading — always treated as feedback
314
+ regardless of comment counts or review state.
315
+ body_comment_count: Copilot's self-reported posted-comment count parsed
316
+ from the review body (``None`` when unrecognised); a positive value
317
+ is treated as feedback even when the REST inline count is 0.
318
+
288
319
  Returns:
289
320
  A ``(status, action_required)`` tuple.
290
321
  """
322
+ # A new-format "Not ready to approve" verdict is always blocking feedback,
323
+ # even with 0 inline/suppressed comments (prose-only verdict).
324
+ if not_approved:
325
+ return ("has-feedback", "address-copilot-review")
326
+
291
327
  # Feedback check has highest priority
292
- if inline_count > 0 or suppressed_count > 0:
328
+ if inline_count > 0 or suppressed_count > 0 or (body_comment_count or 0) > 0:
293
329
  return ("has-feedback", "address-copilot-review")
294
330
 
295
331
  if review_state == "CHANGES_REQUESTED":
@@ -350,6 +386,7 @@ def get_copilot_review_status(pr_number: int, repo: str, head_sha: str) -> dict[
350
386
  review_node_id: str | None = review.get("node_id")
351
387
  review_state: str = review.get("state", "")
352
388
  submitted_at: str | None = review.get("submitted_at")
389
+ review_body: str = review.get("body") or ""
353
390
 
354
391
  inline_count = _count_inline_comments(pr_number, repo, review_id)
355
392
 
@@ -362,7 +399,20 @@ def get_copilot_review_status(pr_number: int, repo: str, head_sha: str) -> dict[
362
399
  file=sys.stderr,
363
400
  )
364
401
 
365
- status, action_required = _classify_review_status(review_state, inline_count, suppressed_count)
402
+ # New CCR private-preview format: the verdict and suppressed comments live in
403
+ # the review *body*, not as GitHub-minimized comments. Merge the body-derived
404
+ # signals so a body-only "Not ready to approve" review is not misread as clean.
405
+ not_approved = parse_verdict(review_body) == VERDICT_NOT_APPROVE
406
+ body_comment_count = parse_reported_comment_count(review_body)
407
+ suppressed_count = max(suppressed_count, parse_suppressed_count(review_body))
408
+
409
+ status, action_required = _classify_review_status(
410
+ review_state,
411
+ inline_count,
412
+ suppressed_count,
413
+ not_approved=not_approved,
414
+ body_comment_count=body_comment_count,
415
+ )
366
416
 
367
417
  review_url = f"https://github.com/{repo}/pull/{pr_number}#pullrequestreview-{review_id}"
368
418
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentic-devtools
3
- Version: 0.2.331
3
+ Version: 0.2.332
4
4
  Summary: Agentic devtools integrate Jira, DevOps & more
5
5
  Author: ayaiayorg
6
6
  License-Expression: MIT
@@ -1,5 +1,5 @@
1
1
  agentic_devtools/__init__.py,sha256=J_Zw_vWKghk-cLmqI83hXQmSiS8zMhGIHM5WPLDkZuo,242
2
- agentic_devtools/_version.py,sha256=Ls08JXA_HbUU5N4V14JTChlvbbcijX3rrjQK-1BNEFs,524
2
+ agentic_devtools/_version.py,sha256=W05wKAPL9NTr_eL2ykSOaNckhAZGNqg5k74bBouriC0,524
3
3
  agentic_devtools/agdt_gitignore.py,sha256=aBPBQe7M0GLH8NIp1NsyN9ZiO80fNGOi46IcT5A4SK4,1569
4
4
  agentic_devtools/background_tasks.py,sha256=IVC1XJKQzPBP8wCRNCZX_iZCg9FJY_GBUzddSJj7xqw,17473
5
5
  agentic_devtools/config.py,sha256=DEVxTVZhsQbVQwO9qdB_1h19aBpjMzF6xJY_ePYLGLs,15629
@@ -136,7 +136,7 @@ agentic_devtools/cli/ci/agent_assignment.py,sha256=UFlSNju1exEjDvrzHfiQ5snGcd2ka
136
136
  agentic_devtools/cli/ci/commands.py,sha256=Y4dJUuPF9zfXCggyXM_fctatTYqoZ3l0WmvujU-RD6o,6465
137
137
  agentic_devtools/cli/ci/delete_review_comments_command.py,sha256=Fqv8zl33oY5z0BmUbyxDAoRxJen8TtgHa1WHDRVaXTU,8237
138
138
  agentic_devtools/cli/ci/exceptions.py,sha256=zpKWOgnBbHHQgAQRvdRW4VFnE_qdgqgmo8r1H-x47l4,1086
139
- agentic_devtools/cli/ci/github_provider.py,sha256=Qqsn6-nguIgB0KZJ_R1XmbR5V0PQtTx9EL7_3Fx8Xtc,202343
139
+ agentic_devtools/cli/ci/github_provider.py,sha256=296yPPaNFtedDM1o_aa6JAVOs8t_qGOVSR1EiXyXznE,200944
140
140
  agentic_devtools/cli/ci/guards.py,sha256=-kfmTMb4KoMeyHFTUJjfDWoXbNZ3waX-9NdBrq1Bcy4,26927
141
141
  agentic_devtools/cli/ci/job_logs.py,sha256=s3Ufinl2WQPVISq8g11ZXU5GJ0WCKDmZlbyGJzvJEVU,9911
142
142
  agentic_devtools/cli/ci/logging_config.py,sha256=Tw_h8LB709gbMd0WZMATLAPIJ1r_c_nE18Bi4bvldHA,2340
@@ -161,7 +161,7 @@ agentic_devtools/cli/ci/pipeline/command.py,sha256=CmsncrER2mgFYz8QAl5NbVzaCLdoH
161
161
  agentic_devtools/cli/ci/pipeline/deferral.py,sha256=bNw5znZ0k_fhP1AdPo6hgZ9y-q86JrE-v_nBf1XJBSM,8241
162
162
  agentic_devtools/cli/ci/pipeline/exceptions.py,sha256=4UXy2weeQZex_lp4sILNxlYfGjetxbR-tjms2ReT4iw,451
163
163
  agentic_devtools/cli/ci/pipeline/exclusion.py,sha256=mrOG5G1-m3U-GM4YgtMTuzmoj02TXB6GbNxemRoLtV4,931
164
- agentic_devtools/cli/ci/pipeline/gate_verdict.py,sha256=PpZCakSiKSwzyLCodJ_mTIR4LB11sIHtSwldsk9SZn0,17723
164
+ agentic_devtools/cli/ci/pipeline/gate_verdict.py,sha256=Fv_sOYIYGtJ5AczBxjTaI6rVOUYlCpfMSSl8kdO8C18,17997
165
165
  agentic_devtools/cli/ci/pipeline/models.py,sha256=L7qhU286K9TB6b_g6N9G-_aeYkDHRVM1JJ1Q0dWrt5U,2166
166
166
  agentic_devtools/cli/ci/pipeline/runner.py,sha256=lUJNXPZKbI7Tfkj_xC2-_Im4RqkRbh-vsMbRCJCATlo,9657
167
167
  agentic_devtools/cli/ci/pipeline/session_detector.py,sha256=dWJyZIv7NcJNUl7TIsYqNfzNmZu3vAxHaQp1wMqe70o,8926
@@ -171,7 +171,7 @@ agentic_devtools/cli/ci/pipeline/summary.py,sha256=CCvIPuje1hYvR1OIgogF-2SN5au_S
171
171
  agentic_devtools/cli/ci/pipeline/actions/__init__.py,sha256=HbtdDtLr_ut8qWedTCRuZujKHWqo8lJfymxPpLoDF5E,1292
172
172
  agentic_devtools/cli/ci/pipeline/actions/apply_suggestions.py,sha256=HuCdn-cFvMJ_inEEBXI1A6eOE9voUBk7DRkiJGgxvtc,21149
173
173
  agentic_devtools/cli/ci/pipeline/actions/approve.py,sha256=CZKPihPg6L1o_8AymoGsnX5BfujCbYB8q5xr8VQ6YY8,6584
174
- agentic_devtools/cli/ci/pipeline/actions/dispatch_repair.py,sha256=eo-DTOBbnN3xw4yXKRe2yJczK8CluaEgCkpBX3IOc-0,14533
174
+ agentic_devtools/cli/ci/pipeline/actions/dispatch_repair.py,sha256=8WCjpLsTSdTUCQCo4XBQI2EMTepuIbNHQo4YaIipeq8,16194
175
175
  agentic_devtools/cli/ci/pipeline/actions/guards.py,sha256=UIkTgv5_aKqSVNRFlvl_JiAdFswm1uV5VJJfgu5FmFc,4101
176
176
  agentic_devtools/cli/ci/pipeline/actions/merge.py,sha256=LOfPoYITFrvpO9H5UJHiXnbykStT0J3GnCmfJiGDVrA,9925
177
177
  agentic_devtools/cli/ci/pipeline/actions/publish.py,sha256=A00Lk-vZKnhOCN2O5mkks0tayr7NA5eZfUA95CUP7gs,3990
@@ -240,7 +240,8 @@ agentic_devtools/cli/github/__init__.py,sha256=HmNZ0PuYe8owe8qS5j5C1MzrKi772eGCM
240
240
  agentic_devtools/cli/github/apply_thread_autofix.py,sha256=PBJ8MwWaB92lCBOOzc-3S7ykYf5i2CswFUo-4XUneL0,32037
241
241
  agentic_devtools/cli/github/async_commands.py,sha256=2drgrnay85RVR_oj1j7-qdBv-uusAvgvUbKwvNmO6o0,33688
242
242
  agentic_devtools/cli/github/browser_apply_autofix.py,sha256=j13V7djjMrdOJikYaK8x4z1soe9Su6wQ6zq7L1uXCS8,19441
243
- agentic_devtools/cli/github/copilot_review_status.py,sha256=g5CG1x5ojCKrOsYB5MaKLgX8M4aeYewUMqngN6Oc1yQ,15646
243
+ agentic_devtools/cli/github/ccr_review_format.py,sha256=2VDug2ApSjBmU5Hk53Nd8jIjR4Y6ciL91qw_w79IyOs,11028
244
+ agentic_devtools/cli/github/copilot_review_status.py,sha256=2infcWJe_bg4vLHDI6XHLL863lQJybnMqc7anuG63Cw,17987
244
245
  agentic_devtools/cli/github/issue_commands.py,sha256=bZYs_0n-T3V4WlG2BX-xK4qH9uqk4e20fpvWWf3ZV6w,19964
245
246
  agentic_devtools/cli/github/issue_dedup.py,sha256=g9HEX3tbAbZjt_Z9FN8WXD_d-mhDUajrS2Gne7nJ1EY,3432
246
247
  agentic_devtools/cli/github/issue_dedup_integration.py,sha256=C9-0nMds7jZOKweNMT09yONcvtH6X6k88TH3yOpsnvc,14433
@@ -888,8 +889,8 @@ agentic_devtools/_bundled_skills/prompts/speckit.plan.prompt.md,sha256=IJja5r2Sd
888
889
  agentic_devtools/_bundled_skills/prompts/speckit.specify.prompt.md,sha256=eyzE3GRi2hyW30a6xPYOU7q6MJf0skrD-baEGURYqpg,31
889
890
  agentic_devtools/_bundled_skills/prompts/speckit.tasks.prompt.md,sha256=iPxXwon5nV6dNcJV8-JoP3PssKUVXctNiG-C9SsRhB8,29
890
891
  agentic_devtools/_bundled_skills/prompts/speckit.taskstoissues.prompt.md,sha256=L5Y21PMSoUcPAAdHy2Jnf-wGVdi04jV_pPvyOJZfpm0,37
891
- agentic_devtools-0.2.331.dist-info/METADATA,sha256=ghH6NhwE72ovCEGBbU8bQYEsRChuhMu8L93H2SlF01s,29796
892
- agentic_devtools-0.2.331.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
893
- agentic_devtools-0.2.331.dist-info/entry_points.txt,sha256=Xs0VhRkREzs61FdX63Ed1qa1-WDeBppn7BvMdRIYYZ4,11395
894
- agentic_devtools-0.2.331.dist-info/licenses/LICENSE,sha256=yBEDdICksxhBYLWoERKp9MTqwGnUF6Ryj9BTLwXTc6k,1082
895
- agentic_devtools-0.2.331.dist-info/RECORD,,
892
+ agentic_devtools-0.2.332.dist-info/METADATA,sha256=2BJ-K1TYEWIAepVS_EtES8e15vcoX5DWhAWiTDoQOKU,29796
893
+ agentic_devtools-0.2.332.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
894
+ agentic_devtools-0.2.332.dist-info/entry_points.txt,sha256=Xs0VhRkREzs61FdX63Ed1qa1-WDeBppn7BvMdRIYYZ4,11395
895
+ agentic_devtools-0.2.332.dist-info/licenses/LICENSE,sha256=yBEDdICksxhBYLWoERKp9MTqwGnUF6Ryj9BTLwXTc6k,1082
896
+ agentic_devtools-0.2.332.dist-info/RECORD,,