agentic-devtools 0.2.415__py3-none-any.whl → 0.2.417__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.415'
22
- __version_tuple__ = version_tuple = (0, 2, 415)
21
+ __version__ = version = '0.2.417'
22
+ __version_tuple__ = version_tuple = (0, 2, 417)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -87,10 +87,10 @@ class ApplySuggestionsAction:
87
87
  snapshot.review_state == "CHANGES_REQUESTED"
88
88
  or (snapshot.review_state == "COMMENTED" and snapshot.copilot_review_inline_count != 0)
89
89
  )
90
- has_unresolved_prior_threads = snapshot.unresolved_threads > 0
91
- preconditions["has_actionable_review"] = has_actionable_review or has_unresolved_prior_threads
90
+ has_repairable_threads = snapshot.repairable_threads > 0
91
+ preconditions["has_actionable_review"] = has_actionable_review or has_repairable_threads
92
92
 
93
- if not has_actionable_review and not has_unresolved_prior_threads:
93
+ if not has_actionable_review and not has_repairable_threads:
94
94
  return ActionResult(
95
95
  name=self.name,
96
96
  decision=ActionDecision.SKIP,
@@ -9,7 +9,7 @@ from agentic_devtools.cli.ci.guards import (
9
9
  check_deduplication,
10
10
  is_duplicate_trigger,
11
11
  )
12
- from agentic_devtools.cli.ci.models import ReviewInfo
12
+ from agentic_devtools.cli.ci.models import ReviewCommentInfo, ReviewInfo
13
13
  from agentic_devtools.cli.ci.pipeline.deferral import read_active_suppressed_deferral
14
14
  from agentic_devtools.cli.ci.pipeline.exclusion import ExclusionContext
15
15
  from agentic_devtools.cli.ci.pipeline.gate_verdict import (
@@ -17,10 +17,14 @@ from agentic_devtools.cli.ci.pipeline.gate_verdict import (
17
17
  REASON_NEW_CCR_NOT_APPROVED,
18
18
  REASON_SUPPRESSED_COMMENTS,
19
19
  REASON_UNPARSED_SUPPRESSION,
20
- select_prior_actionable_reviews,
20
+ is_copilot_or_synthetic_review,
21
21
  )
22
22
  from agentic_devtools.cli.ci.pipeline.models import ActionDecision, ActionResult
23
- from agentic_devtools.cli.ci.pipeline.snapshot import DerivedState, PRStateSnapshot
23
+ from agentic_devtools.cli.ci.pipeline.snapshot import (
24
+ REPAIRABLE_REVIEW_STATES,
25
+ DerivedState,
26
+ PRStateSnapshot,
27
+ )
24
28
  from agentic_devtools.cli.ci.provider import CIPlatformProvider
25
29
  from agentic_devtools.cli.github.ccr_review_format import parse_suppressed_count
26
30
 
@@ -98,16 +102,13 @@ def _is_copilot_review_actionable(snapshot: PRStateSnapshot) -> bool:
98
102
  return _actionable_copilot_review_id(snapshot) > 0
99
103
 
100
104
 
101
- def _list_prior_actionable_copilot_reviews(snapshot: PRStateSnapshot) -> list[ReviewInfo]:
102
- """Return actionable Copilot reviews not already owned by the gate verdict.
103
-
104
- Provenance is scoped to the gate verdict's ``review_id`` (not HEAD's commit
105
- SHA) via the shared :func:`select_prior_actionable_reviews` predicate, so a
106
- HEAD move alone (e.g. ``squash``/``takeover``) cannot change the selection.
107
- """
108
- verdict = snapshot.copilot_gate_verdict
109
- verdict_review_id = verdict.review_id if verdict is not None else 0
110
- return select_prior_actionable_reviews(snapshot.reviews, verdict_review_id)
105
+ def _list_repairable_copilot_reviews(snapshot: PRStateSnapshot) -> list[ReviewInfo]:
106
+ """Return Copilot/synthetic reviews that can own repairable unresolved threads."""
107
+ return [
108
+ review
109
+ for review in snapshot.reviews
110
+ if is_copilot_or_synthetic_review(review) and review.state in REPAIRABLE_REVIEW_STATES
111
+ ]
111
112
 
112
113
 
113
114
  def _declared_suppressed_counts_by_review(snapshot: PRStateSnapshot, review_ids: list[int]) -> dict[int, int]:
@@ -134,11 +135,77 @@ def _has_stuck_prior_review_threads(snapshot: PRStateSnapshot) -> bool:
134
135
  return (
135
136
  snapshot.ci_status == "passing"
136
137
  and snapshot.copilot_review_id == 0
137
- and snapshot.unresolved_threads > 0
138
- and bool(_list_prior_actionable_copilot_reviews(snapshot))
138
+ and snapshot.repairable_threads > 0
139
+ and bool(_list_repairable_copilot_reviews(snapshot))
139
140
  )
140
141
 
141
142
 
143
+ def _select_repairable_thread_owner_reviews(
144
+ provider: CIPlatformProvider,
145
+ snapshot: PRStateSnapshot,
146
+ repairable_reviews: list[ReviewInfo],
147
+ ) -> tuple[list[ReviewInfo], dict[int, list[ReviewCommentInfo]]]:
148
+ """Return reviews that own unresolved repairable threads, plus fetched comments."""
149
+ thread_lookup = getattr(provider, "list_review_threads_by_thread_id", None)
150
+ if not callable(thread_lookup):
151
+ return repairable_reviews, {}
152
+
153
+ try:
154
+ thread_states = thread_lookup(snapshot.pr_number)
155
+ except Exception as exc:
156
+ logger.warning(
157
+ "PR #%d: Failed to derive repairable-thread ownership from thread state: %s",
158
+ snapshot.pr_number,
159
+ exc,
160
+ )
161
+ return repairable_reviews, {}
162
+
163
+ if not isinstance(thread_states, dict):
164
+ return repairable_reviews, {}
165
+
166
+ # Validate the entire mapping before processing: fall back to all candidates on any
167
+ # malformed entry so a mixed valid/malformed mapping never silently excludes an owning
168
+ # review from the repair payload.
169
+ for state in thread_states.values():
170
+ if not (
171
+ isinstance(state, tuple)
172
+ and len(state) == 2
173
+ and type(state[0]) is bool
174
+ and isinstance(state[1], tuple)
175
+ and all(type(c) is int and c >= 0 for c in state[1])
176
+ ):
177
+ logger.warning(
178
+ "PR #%d: Malformed thread state entry detected; falling back to all repairable reviews.",
179
+ snapshot.pr_number,
180
+ )
181
+ return repairable_reviews, {}
182
+
183
+ unresolved_comment_ids = {comment_id for state in thread_states.values() if not state[0] for comment_id in state[1]}
184
+ if not unresolved_comment_ids:
185
+ return repairable_reviews, {}
186
+
187
+ owner_ids: set[int] = set()
188
+ comments_by_review_id: dict[int, list[ReviewCommentInfo]] = {}
189
+ for review in repairable_reviews:
190
+ try:
191
+ comments = provider.list_review_comments(snapshot.pr_number, review.id)
192
+ except Exception as exc:
193
+ logger.warning(
194
+ "PR #%d: Failed to derive repairable-thread ownership from review %d comments: %s",
195
+ snapshot.pr_number,
196
+ review.id,
197
+ exc,
198
+ )
199
+ return repairable_reviews, {}
200
+ comments_by_review_id[review.id] = comments
201
+ if any(comment.id in unresolved_comment_ids for comment in comments if comment.id >= 0):
202
+ owner_ids.add(review.id)
203
+
204
+ if not owner_ids:
205
+ return repairable_reviews, comments_by_review_id
206
+ return [review for review in repairable_reviews if review.id in owner_ids], comments_by_review_id
207
+
208
+
142
209
  class DispatchRepairAction:
143
210
  """Dispatch a repair when CI fails or actionable review feedback exists.
144
211
 
@@ -252,23 +319,74 @@ class DispatchRepairAction:
252
319
 
253
320
  actionable_review_id = _actionable_copilot_review_id(snapshot)
254
321
  review_actionable = actionable_review_id > 0
255
- prior_reviews = _list_prior_actionable_copilot_reviews(snapshot)
256
- prior_reviews.sort(
322
+ repairable_reviews = _list_repairable_copilot_reviews(snapshot)
323
+ repairable_reviews.sort(
257
324
  key=lambda r: (
258
325
  r.submitted_at if isinstance(r.submitted_at, str) else "",
259
326
  r.id,
260
327
  ),
261
328
  reverse=True,
262
329
  )
330
+ repairable_owner_reviews = repairable_reviews
331
+ ownership_comments_cache: dict[int, list[ReviewCommentInfo]] = {}
263
332
  stuck_prior_threads = _has_stuck_prior_review_threads(snapshot)
333
+ if stuck_prior_threads and repairable_reviews:
334
+ repairable_owner_reviews, ownership_comments_cache = _select_repairable_thread_owner_reviews(
335
+ provider,
336
+ snapshot,
337
+ repairable_reviews,
338
+ )
264
339
  ci_failing = snapshot.ci_status == "failing"
265
340
  ci_passing = snapshot.ci_status == "passing"
266
341
  # Use the *effective* actionable review id (the review the gate evaluated),
267
342
  # which may differ from ``copilot_review_id`` for a new-CCR review or a
268
343
  # prior-commit review selected by diff-hash freshness.
269
- review_context_id = (
270
- actionable_review_id if review_actionable else (prior_reviews[0].id if stuck_prior_threads else 0)
271
- )
344
+ review_context_id = actionable_review_id
345
+ # True when the multi-owner loop below already checked is_duplicate_trigger for the
346
+ # chosen review_context_id; suppresses the redundant general check that follows.
347
+ _review_context_dedup_checked = False
348
+ if not review_actionable and stuck_prior_threads and repairable_owner_reviews:
349
+ # Find the first owner review that does not already have an active trigger
350
+ # marker. Picking [0] blindly stalls the pipeline permanently when [0] is
351
+ # already marked but a later owner has never been dispatched.
352
+ chosen_id = 0
353
+ all_owners_duplicated = True
354
+ for _candidate in repairable_owner_reviews:
355
+ try:
356
+ if not is_duplicate_trigger(provider, snapshot.pr_number, _candidate.id):
357
+ chosen_id = _candidate.id
358
+ all_owners_duplicated = False
359
+ _review_context_dedup_checked = True
360
+ break
361
+ except Exception as _exc:
362
+ logger.warning(
363
+ "PR #%d: Review-ID dedup check failed for candidate review %d: %s",
364
+ snapshot.pr_number,
365
+ _candidate.id,
366
+ _exc,
367
+ )
368
+ # Fail-open: use this candidate and proceed with dispatch.
369
+ # Mark as checked so the general dedup block below does not retry
370
+ # the same call on a transient API error.
371
+ chosen_id = _candidate.id
372
+ all_owners_duplicated = False
373
+ _review_context_dedup_checked = True
374
+ break
375
+ if all_owners_duplicated:
376
+ _all_ids = [r.id for r in repairable_owner_reviews]
377
+ logger.info(
378
+ "PR #%d: Trigger comment already exists for all repairable owner reviews %s — skipping",
379
+ snapshot.pr_number,
380
+ _all_ids,
381
+ )
382
+ return ActionResult(
383
+ name=self.name,
384
+ decision=ActionDecision.SKIP,
385
+ details=f"Repair already dispatched for all repairable owner reviews: {_all_ids}",
386
+ )
387
+ # Invariant: all_owners_duplicated is False only when a candidate break set chosen_id.
388
+ assert chosen_id > 0 # noqa: S101
389
+ review_context_id = chosen_id
272
390
  if review_context_id > 0:
273
391
  # Check for an active suppressed-comment deferral marker only when the
274
392
  # deferred review is the sole repair reason. CI failures and stuck
@@ -295,25 +413,27 @@ class DispatchRepairAction:
295
413
  exc,
296
414
  )
297
415
 
298
- # Check review-ID level deduplication first (FR-012).
299
- # Applies to both normal actionable reviews on HEAD and stuck prior-review
300
- # thread repairs (where review context is from a prior commit review).
301
- try:
302
- if is_duplicate_trigger(provider, snapshot.pr_number, review_context_id):
303
- logger.info(
304
- "PR #%d: Trigger comment already exists for review_id=%d — skipping",
305
- snapshot.pr_number,
306
- review_context_id,
307
- )
308
- return ActionResult(
309
- name=self.name,
310
- decision=ActionDecision.SKIP,
311
- details=f"Repair already dispatched for review_id={review_context_id}",
312
- )
313
- except Exception as exc:
314
- logger.warning("PR #%d: Review-ID dedup check failed: %s", snapshot.pr_number, exc)
315
- # Fail-open: proceed with dispatch on transient API failures; the
316
- # review-ID dedup guard is best-effort and should not block repair.
416
+ # Check review-ID level deduplication (FR-012).
417
+ # Applies to normal actionable reviews on HEAD and stuck prior-review thread
418
+ # repairs. Skipped when the multi-owner loop above already confirmed that
419
+ # review_context_id is not duplicated, to avoid a redundant double call.
420
+ if not _review_context_dedup_checked:
421
+ try:
422
+ if is_duplicate_trigger(provider, snapshot.pr_number, review_context_id):
423
+ logger.info(
424
+ "PR #%d: Trigger comment already exists for review_id=%d — skipping",
425
+ snapshot.pr_number,
426
+ review_context_id,
427
+ )
428
+ return ActionResult(
429
+ name=self.name,
430
+ decision=ActionDecision.SKIP,
431
+ details=f"Repair already dispatched for review_id={review_context_id}",
432
+ )
433
+ except Exception as exc:
434
+ logger.warning("PR #%d: Review-ID dedup check failed: %s", snapshot.pr_number, exc)
435
+ # Fail-open: proceed with dispatch on transient API failures; the
436
+ # review-ID dedup guard is best-effort and should not block repair.
317
437
 
318
438
  dedup_kwargs = {"max_dispatches": 1} if ci_failing and not (review_actionable or stuck_prior_threads) else {}
319
439
 
@@ -388,12 +508,9 @@ class DispatchRepairAction:
388
508
  declared_author_comment_counts_by_review: dict[int, int] = {}
389
509
  if review_repair_needed and review_context_id:
390
510
  if stuck_prior_threads:
391
- prior_ids = [r.id for r in prior_reviews]
392
- # When the verdict review is also actionable, include it alongside the prior
393
- # reviews so its comments appear in the repair payload. Deduplicate in case
394
- # review_context_id is already present in prior_ids.
395
- extra = [review_context_id] if review_actionable and review_context_id not in prior_ids else []
396
- review_ids = prior_ids + extra
511
+ review_ids = [r.id for r in repairable_owner_reviews]
512
+ if review_context_id not in review_ids:
513
+ review_ids.append(review_context_id)
397
514
  else:
398
515
  review_ids = [review_context_id]
399
516
  declared_author_comment_counts_by_review = _declared_suppressed_counts_by_review(snapshot, review_ids)
@@ -401,7 +518,10 @@ class DispatchRepairAction:
401
518
  seen_comment_keys: set[tuple[int, int]] = set()
402
519
  for review_id in review_ids:
403
520
  try:
404
- for comment in provider.list_review_comments(snapshot.pr_number, review_id):
521
+ comments = ownership_comments_cache.get(review_id)
522
+ if comments is None:
523
+ comments = provider.list_review_comments(snapshot.pr_number, review_id)
524
+ for comment in comments:
405
525
  dedup_key = (review_id, comment.id) if comment.id < 0 else (0, comment.id)
406
526
  if dedup_key in seen_comment_keys:
407
527
  continue
@@ -111,7 +111,7 @@ class ResolveThreadsAction:
111
111
  from agentic_devtools.cli.ci.pipeline.gate_verdict import select_prior_actionable_reviews
112
112
 
113
113
  verdict = snapshot.copilot_gate_verdict
114
- verdict_review_id = verdict.review_id if verdict is not None else 0
114
+ verdict_review_id = verdict.review_id if verdict is not None and verdict.review_id > 0 else None
115
115
  prior_reviews = select_prior_actionable_reviews(snapshot.reviews, verdict_review_id)
116
116
 
117
117
  if not prior_reviews:
@@ -175,23 +175,36 @@ class ResolveThreadsAction:
175
175
  # same source the snapshot uses. The provider's thread-signals cache is
176
176
  # invalidated by the resolve/unresolve mutations, so this observes the
177
177
  # post-resolution state.
178
- requeried, degraded = count_unresolved_prior_threads(
179
- provider, snapshot.pr_number, snapshot.reviews, verdict_review_id
178
+ requeried_blocking, requeried_repairable, degraded, unknown_provenance = count_unresolved_prior_threads(
179
+ provider,
180
+ snapshot.pr_number,
181
+ snapshot.reviews,
182
+ verdict_review_id,
183
+ verdict=verdict,
180
184
  )
181
185
  if degraded:
182
186
  # Thread state is unavailable — fail closed with the conservative
183
187
  # pre-re-query estimate and the helper's degraded sentinel rather
184
188
  # than trusting an unknown count.
185
- unresolved_total = max(requeried, unresolved, snapshot.unresolved_threads - resolved, 0)
189
+ unresolved_total = max(requeried_blocking, unresolved, snapshot.unresolved_threads - resolved, 0)
190
+ # ``resolved`` is a per-review aggregate and can double-count the same
191
+ # underlying thread across reviews. Preserve the previously measured
192
+ # repairable inventory when re-query is degraded instead of subtracting
193
+ # this non-deduplicated value.
194
+ repairable_total = max(requeried_repairable, snapshot.repairable_threads, 0)
186
195
  else:
187
- unresolved_total = requeried
196
+ unresolved_total = requeried_blocking
197
+ repairable_total = requeried_repairable
198
+
188
199
  derived.set("unresolved_threads", unresolved_total)
189
- # Propagate the re-query's degraded status so downstream actions
190
- # (approve, merge) know whether the thread count is authoritative.
200
+ derived.set("repairable_threads", repairable_total)
201
+ # Propagate the re-query's degraded and unknown_provenance status so downstream
202
+ # actions (approve, merge) know whether the thread count is authoritative.
191
203
  # A failed re-query after a healthy snapshot must mark state as degraded
192
204
  # (not leave the snapshot's False in place); a successful re-query
193
205
  # replaces whatever the snapshot held.
194
206
  derived.set("unresolved_threads_degraded", degraded)
207
+ derived.set("unresolved_threads_unknown_provenance", unknown_provenance)
195
208
 
196
209
  details = f"Resolved {resolved} thread(s), {unresolved_total} left open"
197
210
  if suppressed:
@@ -503,7 +503,7 @@ def copilot_review_gate_passed(
503
503
  )
504
504
 
505
505
 
506
- def select_prior_actionable_reviews(reviews: list[ReviewInfo], verdict_review_id: int) -> list[ReviewInfo]:
506
+ def select_prior_actionable_reviews(reviews: list[ReviewInfo], verdict_review_id: int | None) -> list[ReviewInfo]:
507
507
  """Return actionable Copilot/synthetic reviews not already owned by the gate verdict.
508
508
 
509
509
  This is the **single** definition of the "which reviews count as prior" predicate.
@@ -520,11 +520,11 @@ def select_prior_actionable_reviews(reviews: list[ReviewInfo], verdict_review_id
520
520
  thread (e.g. ``squash``/``takeover`` minting a new commit SHA) does not change
521
521
  the selection, unlike a HEAD-commit-SHA comparison.
522
522
 
523
- Fails closed: when *verdict_review_id* is ``<= 0`` (no verdict evaluated, or
524
- evaluation failed), every actionable review is treated as prior — i.e. this
525
- degenerates to counting all of them, never fewer. Input order is preserved.
523
+ Fails closed: when provenance is unknown (``verdict_review_id`` is ``None`` or
524
+ ``<= 0``) every actionable review is treated as prior — i.e. this degenerates
525
+ to counting all of them, never fewer. Input order is preserved.
526
526
  """
527
- if verdict_review_id <= 0:
527
+ if verdict_review_id is None or verdict_review_id <= 0:
528
528
  return [r for r in reviews if is_copilot_or_synthetic_review(r) and r.state in _ACTIONABLE_PRIOR_REVIEW_STATES]
529
529
  return [
530
530
  r
@@ -61,6 +61,17 @@ class PRStateSnapshot:
61
61
  mapping omitted selected comments, or a review's comments could not be
62
62
  listed), so ``unresolved_threads`` is unknown. The gate fails closed on this signal and
63
63
  the run summary reports "degraded / unknown" instead of a fabricated integer.
64
+ unresolved_threads_unknown_provenance: Whether the gate-verdict review ID could not be
65
+ resolved when ``unresolved_threads`` was computed, so the count covers *all*
66
+ actionable reviews (the conservative count-all floor) rather than being scoped
67
+ to reviews whose ID differs from the verdict's owner. The count is still
68
+ authoritative — it is the maximum possible value — but consumers can use this
69
+ flag to emit extra diagnostics. No additional gate blocking is required: the
70
+ count-all strategy is already the most conservative possible reading.
71
+ repairable_threads: Number of all unresolved Copilot review threads from any
72
+ actionable Copilot review (current or prior). Unlike ``unresolved_threads``,
73
+ this is independent of the gate verdict and is used solely to determine if
74
+ there is repairable work, decoupled from whether those threads are blocking.
64
75
  labels: List of labels on the PR.
65
76
  is_draft: Whether the PR is a draft.
66
77
  mergeable: Whether the PR is mergeable (None if unknown).
@@ -134,6 +145,8 @@ class PRStateSnapshot:
134
145
  copilot_review_pending: bool = False
135
146
  unresolved_threads: int = 0
136
147
  unresolved_threads_degraded: bool = False
148
+ repairable_threads: int = 0
149
+ unresolved_threads_unknown_provenance: bool = False
137
150
  labels: list[str] = field(default_factory=list)
138
151
  is_draft: bool = False
139
152
  mergeable: bool | None = None
@@ -195,6 +208,7 @@ class DerivedState:
195
208
 
196
209
 
197
210
  _DEFAULT_ACTIONABLE_CHECK_NAMES = DEFAULT_ACTIONABLE_CHECK_NAMES
211
+ REPAIRABLE_REVIEW_STATES = frozenset({"CHANGES_REQUESTED", "COMMENTED", "APPROVED"})
198
212
 
199
213
 
200
214
  def build_pr_state_snapshot(
@@ -336,8 +350,19 @@ def build_pr_state_snapshot(
336
350
  # as "prior". When the verdict evaluation above failed closed, its review_id
337
351
  # defaults to 0, so this degrades explicitly to count-all — the same
338
352
  # fail-closed direction the verdict itself takes on exception.
339
- unresolved_threads, unresolved_threads_degraded = count_unresolved_prior_threads(
340
- provider, pr_number, reviews, copilot_gate_verdict.review_id
353
+ effective_verdict_review_id = (
354
+ copilot_gate_verdict.review_id
355
+ if copilot_gate_verdict is not None and copilot_gate_verdict.review_id > 0
356
+ else None
357
+ )
358
+ unresolved_threads, repairable_threads, unresolved_threads_degraded, unresolved_threads_unknown_provenance = (
359
+ count_unresolved_prior_threads(
360
+ provider,
361
+ pr_number,
362
+ reviews,
363
+ effective_verdict_review_id,
364
+ verdict=copilot_gate_verdict,
365
+ )
341
366
  )
342
367
 
343
368
  # Whether the Copilot review actually evaluated by the gate still matches HEAD.
@@ -404,6 +429,8 @@ def build_pr_state_snapshot(
404
429
  copilot_review_pending=copilot_review_pending,
405
430
  unresolved_threads=unresolved_threads,
406
431
  unresolved_threads_degraded=unresolved_threads_degraded,
432
+ repairable_threads=repairable_threads,
433
+ unresolved_threads_unknown_provenance=unresolved_threads_unknown_provenance,
407
434
  labels=list(pr_meta.labels),
408
435
  is_draft=pr_meta.is_draft,
409
436
  mergeable=pr_meta.mergeable,
@@ -472,38 +499,93 @@ def _is_copilot_review_pending(requested_reviewers: list[str]) -> bool:
472
499
  return any(is_copilot_login(reviewer) for reviewer in requested_reviewers)
473
500
 
474
501
 
502
+ def _effective_thread_owner_verdict_id(
503
+ verdict_review_id: int | None,
504
+ verdict: CopilotGateVerdict | None,
505
+ ) -> int | None:
506
+ """Resolve the effective thread-owner review ID for merge-gate provenance.
507
+
508
+ ``None`` represents unknown provenance, which is the fail-closed signal for
509
+ the count-all path. When a verdict object is present it is authoritative:
510
+ a positive ``verdict.review_id`` is used, otherwise provenance remains
511
+ unknown. The legacy integer is used only when no verdict object is supplied.
512
+ """
513
+ if verdict is not None:
514
+ if verdict.review_id > 0:
515
+ return verdict.review_id
516
+ return None
517
+ if verdict_review_id is not None and verdict_review_id > 0:
518
+ return verdict_review_id
519
+ return None
520
+
521
+
475
522
  def count_unresolved_prior_threads(
476
523
  provider: CIPlatformProvider,
477
524
  pr_number: int,
478
525
  reviews: list[ReviewInfo],
479
- verdict_review_id: int,
480
- ) -> tuple[int, bool]:
481
- """Count unresolved Copilot or trusted synthetic review threads not owned by the gate verdict.
482
-
483
- Returns a ``(count, degraded)`` pair. ``count`` is the number of distinct
484
- review threads from genuine Copilot or trusted synthetic reviews — other than
485
- the one the gate verdict already accounts for (``verdict_review_id``) — whose
486
- provider-reported state is unresolved.
526
+ verdict_review_id: int | None,
527
+ *,
528
+ verdict: CopilotGateVerdict | None = None,
529
+ ) -> tuple[int, int, bool, bool]:
530
+ """Count unresolved Copilot or trusted synthetic review threads.
531
+
532
+ Returns a ``(blocking_count, repairable_count, degraded, unknown_provenance)`` tuple.
533
+ ``blocking_count`` (unresolved_threads) is the number of distinct review threads
534
+ from genuine Copilot or trusted synthetic reviews — other than the one the gate
535
+ verdict already accounts for (``verdict_review_id``) — whose provider-reported
536
+ state is unresolved.
537
+
538
+ ``repairable_count`` (repairable_threads) is the total number of unresolved
539
+ threads from ALL genuine or trusted Copilot reviews (including APPROVED reviews),
540
+ regardless of whether they are owned by the gate verdict. This decoupled count
541
+ allows the repair agent to detect genuinely repairable state even when a gate
542
+ block zeroes out the blocking count.
487
543
 
488
544
  Provenance is scoped to ``verdict_review_id``, not to HEAD's commit SHA: this
489
545
  keeps the count HEAD-independent, so a HEAD move that touches no review or
490
546
  thread (e.g. ``squash``/``takeover`` minting a new commit) cannot change it.
491
547
  When ``verdict_review_id <= 0`` (no verdict evaluated, or evaluation failed
492
- closed), this explicitly degenerates to counting every actionable review —
493
- the same fail-closed direction the verdict itself takes.
548
+ closed), this explicitly degenerates to counting every actionable review for
549
+ the blocking count — the same fail-closed direction the verdict itself takes.
494
550
 
495
551
  When the provider cannot report review-thread state (the capability is
496
552
  missing, the lookup failed, the returned mapping omits selected comments, or
497
- a review's comments could not be listed), ``degraded`` is True: the count
498
- falls back to the minimum blocking floor of ``1`` so a capability-less
499
- provider can never present as "0 unresolved threads" and silently open the
500
- merge gate. Every such degradation is logged, so no thread is ever dropped
501
- from the count without a diagnostic.
553
+ a review's comments could not be listed), ``degraded`` is True. Degradation is
554
+ tracked separately for blocking and repairable inventories:
555
+ ``blocking_count`` is floored to ``1`` only when blocking inventory is unknown,
556
+ while ``repairable_count`` is floored to ``1`` when repairable inventory is
557
+ unknown and no unresolved repairable threads were measured. This prevents
558
+ false-open merge gates and also prevents repair dispatch from stalling behind
559
+ an unknown-but-possible repairable inventory.
560
+ Every degradation is logged, so no thread is ever dropped from the count
561
+ without a diagnostic.
502
562
  """
503
- prior_copilot_reviews = select_prior_actionable_reviews(reviews, verdict_review_id)
504
- if not prior_copilot_reviews:
563
+ effective_review_id = _effective_thread_owner_verdict_id(verdict_review_id, verdict)
564
+ unknown_provenance = effective_review_id is None
565
+ if unknown_provenance:
566
+ logger.warning(
567
+ "PR #%d: no concrete thread-owner provenance for prior actionable reviews; "
568
+ "falling back to count-all (conservative floor — not a query failure)",
569
+ pr_number,
570
+ )
571
+
572
+ # All genuine/trusted Copilot reviews are candidates for repairable-thread counting,
573
+ # regardless of their verdict state (APPROVED reviews can still have unresolved threads).
574
+ all_copilot_reviews = [
575
+ r for r in reviews if is_copilot_or_synthetic_review(r) and r.state in REPAIRABLE_REVIEW_STATES
576
+ ]
577
+ if not all_copilot_reviews:
505
578
  # Nothing to report on — no capability was needed, so this is not degraded.
506
- return 0, False
579
+ return 0, 0, False, unknown_provenance
580
+
581
+ # Blocking classification still uses the filtered (CHANGES_REQUESTED/COMMENTED) set.
582
+ blocking_review_ids = {r.id for r in select_prior_actionable_reviews(reviews, effective_review_id)}
583
+
584
+ def _unknown_inventory_fallback(unknown_provenance: bool) -> tuple[int, int, bool, bool]:
585
+ blocking_floor = 1 if blocking_review_ids else 0
586
+ # We have at least one repairable review candidate, but inventory is
587
+ # unknown; fail closed so repair paths do not stall.
588
+ return blocking_floor, 1, True, unknown_provenance
507
589
 
508
590
  thread_lookup = getattr(provider, "list_review_threads_by_thread_id", None)
509
591
  if not callable(thread_lookup):
@@ -512,7 +594,7 @@ def count_unresolved_prior_threads(
512
594
  " — failing closed with an unknown-thread count",
513
595
  pr_number,
514
596
  )
515
- return 1, True
597
+ return _unknown_inventory_fallback(unknown_provenance)
516
598
 
517
599
  try:
518
600
  thread_states = thread_lookup(pr_number)
@@ -522,14 +604,14 @@ def count_unresolved_prior_threads(
522
604
  " — failing closed with an unknown-thread count",
523
605
  pr_number,
524
606
  )
525
- return 1, True
607
+ return _unknown_inventory_fallback(unknown_provenance)
526
608
  except Exception as exc:
527
609
  logger.warning(
528
610
  "PR #%d: review-thread state unavailable (%s) — failing closed with an unknown-thread count",
529
611
  pr_number,
530
612
  str(exc)[:200],
531
613
  )
532
- return 1, True
614
+ return _unknown_inventory_fallback(unknown_provenance)
533
615
 
534
616
  if not isinstance(thread_states, dict):
535
617
  logger.warning(
@@ -539,7 +621,7 @@ def count_unresolved_prior_threads(
539
621
  pr_number,
540
622
  type(thread_states).__name__,
541
623
  )
542
- return 1, True
624
+ return _unknown_inventory_fallback(unknown_provenance)
543
625
 
544
626
  comment_to_thread: dict[int, str] = {}
545
627
  for thread_key, state in thread_states.items():
@@ -552,7 +634,7 @@ def count_unresolved_prior_threads(
552
634
  pr_number,
553
635
  thread_key,
554
636
  )
555
- return 1, True
637
+ return _unknown_inventory_fallback(unknown_provenance)
556
638
  if not isinstance(state, tuple) or len(state) != 2 or type(state[0]) is not bool:
557
639
  logger.warning(
558
640
  "PR #%d: review-thread state unavailable "
@@ -563,7 +645,7 @@ def count_unresolved_prior_threads(
563
645
  thread_key,
564
646
  state,
565
647
  )
566
- return 1, True
648
+ return _unknown_inventory_fallback(unknown_provenance)
567
649
  comment_ids = state[1]
568
650
  if not isinstance(comment_ids, tuple):
569
651
  logger.warning(
@@ -572,7 +654,7 @@ def count_unresolved_prior_threads(
572
654
  " — failing closed with an unknown-thread count",
573
655
  pr_number,
574
656
  )
575
- return 1, True
657
+ return _unknown_inventory_fallback(unknown_provenance)
576
658
  for comment_id in comment_ids:
577
659
  if type(comment_id) is not int:
578
660
  logger.warning(
@@ -584,7 +666,7 @@ def count_unresolved_prior_threads(
584
666
  thread_key,
585
667
  comment_id,
586
668
  )
587
- return 1, True
669
+ return _unknown_inventory_fallback(unknown_provenance)
588
670
  mapped_thread = comment_to_thread.get(comment_id)
589
671
  if mapped_thread is not None and mapped_thread != thread_key:
590
672
  logger.warning(
@@ -597,27 +679,34 @@ def count_unresolved_prior_threads(
597
679
  mapped_thread,
598
680
  thread_key,
599
681
  )
600
- return 1, True
682
+ return _unknown_inventory_fallback(unknown_provenance)
601
683
  comment_to_thread[comment_id] = thread_key
602
684
 
603
- total_unresolved = 0
604
- missing_thread_state = False
605
- comments_unavailable = False
606
- counted_thread_ids: set[str] = set()
607
- for prior_review in prior_copilot_reviews:
685
+ total_blocking = 0
686
+ total_repairable = 0
687
+ blocking_inventory_unknown = False
688
+ repairable_inventory_unknown = False
689
+ counted_blocking_thread_ids: set[str] = set()
690
+ counted_repairable_thread_ids: set[str] = set()
691
+
692
+ for review in all_copilot_reviews:
693
+ is_blocking = review.id in blocking_review_ids
608
694
  try:
609
- comments = provider.list_review_comments(pr_number, prior_review.id)
695
+ comments = provider.list_review_comments(pr_number, review.id)
610
696
  for comment in comments:
611
697
  if comment.id < 0:
612
698
  continue # Skip synthetic review-body entries
613
699
  if comment.id in comment_to_thread:
614
700
  thread_id = comment_to_thread[comment.id]
615
- if thread_id in counted_thread_ids:
616
- continue
617
- counted_thread_ids.add(thread_id)
618
701
  is_resolved = thread_states[thread_id][0]
702
+
619
703
  if not is_resolved:
620
- total_unresolved += 1
704
+ if thread_id not in counted_repairable_thread_ids:
705
+ counted_repairable_thread_ids.add(thread_id)
706
+ total_repairable += 1
707
+ if is_blocking and thread_id not in counted_blocking_thread_ids:
708
+ counted_blocking_thread_ids.add(thread_id)
709
+ total_blocking += 1
621
710
  continue
622
711
 
623
712
  logger.warning(
@@ -625,28 +714,32 @@ def count_unresolved_prior_threads(
625
714
  pr_number,
626
715
  comment.id,
627
716
  )
628
- missing_thread_state = True
717
+ repairable_inventory_unknown = True
718
+ if is_blocking:
719
+ blocking_inventory_unknown = True
629
720
  except Exception as exc:
630
721
  logger.warning(
631
722
  "PR #%d: failed to list review comments for review %d (%s) — failing closed",
632
723
  pr_number,
633
- prior_review.id,
724
+ review.id,
634
725
  str(exc)[:200],
635
726
  )
636
- comments_unavailable = True
727
+ repairable_inventory_unknown = True
728
+ if is_blocking:
729
+ blocking_inventory_unknown = True
637
730
 
638
- if missing_thread_state:
639
- return max(total_unresolved, 1), True
640
- if comments_unavailable:
641
- if total_unresolved == 0:
731
+ if repairable_inventory_unknown or blocking_inventory_unknown:
732
+ if repairable_inventory_unknown and total_repairable == 0:
642
733
  logger.warning(
643
- "PR #%d: no unresolved threads were measured and at least one review fetch failed"
644
- " — applying the blocking floor of 1 unresolved thread",
734
+ "PR #%d: no unresolved repairable threads were measured and at least one review inventory "
735
+ "is unknown — applying the repairable floor of 1 unresolved thread",
645
736
  pr_number,
646
737
  )
647
- return 1, True
648
- return total_unresolved, True
649
- return total_unresolved, False
738
+ total_repairable = 1
739
+ if blocking_inventory_unknown:
740
+ total_blocking = max(1, total_blocking)
741
+ return total_blocking, total_repairable, True, unknown_provenance
742
+ return total_blocking, total_repairable, False, unknown_provenance
650
743
 
651
744
 
652
745
  def _count_commits(provider: CIPlatformProvider, *, base_branch: str, head_sha: str) -> int:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentic-devtools
3
- Version: 0.2.415
3
+ Version: 0.2.417
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=yIi8ocqIsuh2cqu1EnYhgjeImc0-nP0OgjnFW9ZTHzw,524
2
+ agentic_devtools/_version.py,sha256=X7xzJ5LaWItIS7kP80cFtX-9DK9edflj5atOwis4Joo,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
@@ -169,25 +169,25 @@ agentic_devtools/cli/ci/pipeline/command.py,sha256=RJ_QUhQjSyEobqDUQnPuVMl0-rBWK
169
169
  agentic_devtools/cli/ci/pipeline/deferral.py,sha256=Dga-CQevmTe1PYeUpvptZxbvD3g8jl7Q5kqgZSAY2dg,18305
170
170
  agentic_devtools/cli/ci/pipeline/exceptions.py,sha256=4UXy2weeQZex_lp4sILNxlYfGjetxbR-tjms2ReT4iw,451
171
171
  agentic_devtools/cli/ci/pipeline/exclusion.py,sha256=mrOG5G1-m3U-GM4YgtMTuzmoj02TXB6GbNxemRoLtV4,931
172
- agentic_devtools/cli/ci/pipeline/gate_verdict.py,sha256=LbjuLnb9-EchE-lIFx2g5FYg_kwEPqUJSFMlHadGntw,43993
172
+ agentic_devtools/cli/ci/pipeline/gate_verdict.py,sha256=stMgkDzVmn4Tr0yoyfPs7RXe7BpAWkecWFbY1baXEQg,44021
173
173
  agentic_devtools/cli/ci/pipeline/models.py,sha256=GVhAXMbIEhkuECYUdk6WgZ7-lOOb5sCFn9MfG50iSqY,2456
174
174
  agentic_devtools/cli/ci/pipeline/runner.py,sha256=YfK9QBFQg0ri0kCvYBek3LiQm7Mxv4pXPxiaC5PmIjk,13805
175
175
  agentic_devtools/cli/ci/pipeline/session_detector.py,sha256=dWJyZIv7NcJNUl7TIsYqNfzNmZu3vAxHaQp1wMqe70o,8926
176
- agentic_devtools/cli/ci/pipeline/snapshot.py,sha256=MgW38vpAQhWmYnBUvbMFjaeWAxr9Bo_C8SAbyCrqj-Q,33686
176
+ agentic_devtools/cli/ci/pipeline/snapshot.py,sha256=MQ_drAJKZQ24pLJ7ZqDJk-feeg2dZlykTZMe11RfX0o,39144
177
177
  agentic_devtools/cli/ci/pipeline/suggestions.py,sha256=nM4w1hfDFkT5_DAMMJMDZnFIvKhcvK1ainxcnAU_FkM,26777
178
178
  agentic_devtools/cli/ci/pipeline/summary.py,sha256=ILCNHE845oiqGM6CA8NjaVKBVyxLNu5xTG8GXdhOy_U,13321
179
179
  agentic_devtools/cli/ci/pipeline/actions/__init__.py,sha256=rY6e3yk5ybMINMSdtWvcwZI8I9GGxQAQ90J_dSjipr8,1577
180
- agentic_devtools/cli/ci/pipeline/actions/apply_suggestions.py,sha256=WCtCoJE679587gqgLsoSO5dwEEoGdECuZoKgoc3ah5E,21215
180
+ agentic_devtools/cli/ci/pipeline/actions/apply_suggestions.py,sha256=Y71W3QliyyAHpVGvOvipEezPW3KliYAqu5SW2ux-HBk,21197
181
181
  agentic_devtools/cli/ci/pipeline/actions/approve.py,sha256=AHTm8Mf1kMaQcrS303ZalMaKzpEfYrtMdWDrisWT6kQ,9080
182
182
  agentic_devtools/cli/ci/pipeline/actions/defer_suppressed.py,sha256=EZZMXyopk6oBQCjTRi769EsFGfTUeCqwfzBNNgeREHA,16178
183
183
  agentic_devtools/cli/ci/pipeline/actions/dispatch_conflict_resolution.py,sha256=Dl6EoagioHq74bLURRB_5Mi36Od6JHkbRuG5_za_goc,13754
184
- agentic_devtools/cli/ci/pipeline/actions/dispatch_repair.py,sha256=g9pMNrTc18ays-pRih4sLPI6rj5UTky1t1dA9jnwOA4,21521
184
+ agentic_devtools/cli/ci/pipeline/actions/dispatch_repair.py,sha256=T_4Iz01GWyCm72h2BRlMW7vSfAh0NTFgkMqbBJMVzIA,26813
185
185
  agentic_devtools/cli/ci/pipeline/actions/guards.py,sha256=UIkTgv5_aKqSVNRFlvl_JiAdFswm1uV5VJJfgu5FmFc,4101
186
186
  agentic_devtools/cli/ci/pipeline/actions/merge.py,sha256=MPQxTMaDWpeZBzge2tx8htCNCjYDpF_bV5A0AofllBc,16778
187
187
  agentic_devtools/cli/ci/pipeline/actions/publish.py,sha256=A00Lk-vZKnhOCN2O5mkks0tayr7NA5eZfUA95CUP7gs,3990
188
188
  agentic_devtools/cli/ci/pipeline/actions/rebase.py,sha256=FGykjl5xi9Bg8nq2AE6IB7skdmKJ7Z4iOT4bd2K3B2U,5256
189
189
  agentic_devtools/cli/ci/pipeline/actions/request_review.py,sha256=-BInEFbX7rHY_wKna-ksa5cQV_Arxi7w1Jg1kNdBltE,9173
190
- agentic_devtools/cli/ci/pipeline/actions/resolve_threads.py,sha256=orY7RsqrMsvOmGDa7R-Z96itSFj9N5DICertZdekwOo,9687
190
+ agentic_devtools/cli/ci/pipeline/actions/resolve_threads.py,sha256=zMt9oZGQgYSBeAqYwOawt0UA0KY846D0jsOPMQetLus,10448
191
191
  agentic_devtools/cli/ci/pipeline/actions/squash.py,sha256=woloutgBoaEkZMO6HcLoChCV3S-yeD7T22ygePciRx0,10894
192
192
  agentic_devtools/cli/ci/pipeline/actions/takeover.py,sha256=ms0coVGuT7peZ3XKbYzqzwrAwqwyyvfPzCsPXLPXKws,6394
193
193
  agentic_devtools/cli/ci/pipeline/discovery/__init__.py,sha256=QNdOYvl_wK6qcWOvNJR-wm8EtWD_DPn4barKF5RSmfU,752
@@ -949,8 +949,8 @@ agentic_devtools/_bundled_skills/prompts/speckit.plan.prompt.md,sha256=IJja5r2Sd
949
949
  agentic_devtools/_bundled_skills/prompts/speckit.specify.prompt.md,sha256=eyzE3GRi2hyW30a6xPYOU7q6MJf0skrD-baEGURYqpg,31
950
950
  agentic_devtools/_bundled_skills/prompts/speckit.tasks.prompt.md,sha256=iPxXwon5nV6dNcJV8-JoP3PssKUVXctNiG-C9SsRhB8,29
951
951
  agentic_devtools/_bundled_skills/prompts/speckit.taskstoissues.prompt.md,sha256=L5Y21PMSoUcPAAdHy2Jnf-wGVdi04jV_pPvyOJZfpm0,37
952
- agentic_devtools-0.2.415.dist-info/METADATA,sha256=0YFc2rqKU6aEqndgc0b1TPrbskguyu3vIIraNawyBCg,33466
953
- agentic_devtools-0.2.415.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
954
- agentic_devtools-0.2.415.dist-info/entry_points.txt,sha256=1xH6yqltFD5Gs3qbHN9zpKwwG3nXpWoC7x0w1Ip7LCw,11726
955
- agentic_devtools-0.2.415.dist-info/licenses/LICENSE,sha256=yBEDdICksxhBYLWoERKp9MTqwGnUF6Ryj9BTLwXTc6k,1082
956
- agentic_devtools-0.2.415.dist-info/RECORD,,
952
+ agentic_devtools-0.2.417.dist-info/METADATA,sha256=SHsp34rERnK4tBljYrlOjd_ChdoqFZzZdw7ua1FqsK0,33466
953
+ agentic_devtools-0.2.417.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
954
+ agentic_devtools-0.2.417.dist-info/entry_points.txt,sha256=1xH6yqltFD5Gs3qbHN9zpKwwG3nXpWoC7x0w1Ip7LCw,11726
955
+ agentic_devtools-0.2.417.dist-info/licenses/LICENSE,sha256=yBEDdICksxhBYLWoERKp9MTqwGnUF6Ryj9BTLwXTc6k,1082
956
+ agentic_devtools-0.2.417.dist-info/RECORD,,