a91df787d7
Adds the second of three abandon gates: the estimator (Gate 2) can
mark a work item fundamentally unworkable, transitioning the
workflow ANALYZING -> ABANDONED and triggering a Forgejo close via
Phase 1's decomposed close_act orchestrator. Catches abandon cases
at the cheapest LLM stage, before implementer/reviewer tiers fire.
Substantive:
- EstimatorOutputV1: additive verdict + abandon_reason_category +
abandon_reason_detail fields (pre-Phase-2 outputs still parse).
@model_validator enforces abandon-requires-category atomicity at
parse time — third defense layer beyond MCP setter + outcomes
mapper
- state_machine: estimator_abandon event + (ANALYZING,
estimator_abandon) -> ABANDONED. 57 transitions; invariants clean
- mcp/estimator_builder: estimator_set_verdict setter validates
verdict enum + 9-category whitelist (scope_intractable,
intent_wrong, security_regression, deprecated_dependency,
breaks_protected_invariants, out_of_scope, low_value,
unmaintained_path, policy_violation) + cross-field rules
- outcomes._map_estimator_outcome: dispatch verdict='abandon'
-> estimator_abandon, with confidence-low downgrade to
estimator_done (honors the agent prompt's documented "high or
medium" requirement)
- estimator_abandon_side_effects.py: per-state side-effect tick
modeled on grooming_side_effects.py; invokes close_act with
cause=Cause.ESTIMATOR_ABANDON + event_type='estimator_abandon'
- _events.py: shared latest_transition_event +
workflows_with_latest_transition_in helpers; dialect-aware
payload['event'] extraction (SQLite json_extract +
PostgreSQL ->>); centralizes the event_type='transition' +
payload['event'] convention that side-effect ticks consume
- gate2_abandon_config.py: CONTROLLER_GATE2_ABANDON_ENABLED kill
switch (default false). Fresh Phase 2 deploys are audit-only
until operator explicitly enables; dry_run shared with grooming
for unified safe-rollout staging
- .opencode/agents/estimator-implementation.md: GATE 2 ABANDON
section with 9-category criteria + low_value disqualifier ("PR
cites an issue/ticket -> route to reviewer instead")
Round-2 adversarial-review fixes (all required pre-commit):
- forgejo_writes.close_issue / close_act: NEW cause + event_type
kwargs (defaults preserve Phase 1 grooming behavior; Phase 2
callsite overrides). Fixes audit-trail attribution: telemetry
queries SELECT WHERE cause='estimator_abandon' now return the
right rows. Phase 1 regression test pins the grooming defaults
- tick.py operator_unstick lookback: dialect-aware json_extract
fix (Phase 1 carry-over bug; would silently no-op on PostgreSQL)
- grooming_side_effects.py: idempotency filter now keys on
check_name set (grooming check_names only) so a Phase 1 close
and a Phase 2 close on the same workflow don't cross-cancel
Tests (+50): TestEstimatorOutputV1Phase2,
TestEstimatorAbandonStateMachine, TestMapEstimatorOutcomePhase2
(including confidence-low downgrade), TestEstimatorSetVerdict
(all 9 categories + cross-field rules), TestEventsHelper,
TestEstimatorAbandonSideEffectTick (including
test_close_writes_estimator_abandon_cause_and_event_type pinning
the audit-trail attribution, and Phase 1 regression guard).
Doc-contract test asserts all 9 categories appear in the agent
prompt. 1509/1509 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1193 lines
44 KiB
Python
1193 lines
44 KiB
Python
"""Forgejo write protocol for the master.
|
|
|
|
The master writes three categories of operator-visible state into
|
|
Forgejo:
|
|
1. Status comments (verbose what-happened summaries on the PR
|
|
timeline)
|
|
2. Labels (machine-readable + glanceable state markers)
|
|
3. Merge calls (Phase 1d-3c-3, separate module)
|
|
|
|
Per plan v9 simplified write ordering:
|
|
- Forgejo write FIRST with fingerprint-based idempotency
|
|
- DB commit SECOND
|
|
- Reconciliation tick syncs DB↔Forgejo if a crash happens between
|
|
|
|
This module ships the controller-side write logic. Forgejo HTTP is
|
|
injected via callbacks so tests don't need a live Forgejo instance.
|
|
Production wires the callbacks to the existing
|
|
``tools/_claim_runtime.get/post`` + ``_status_comments`` helpers.
|
|
|
|
Fingerprint pattern (carried forward from the existing
|
|
``_status_comments`` module): each operator-visible state has a
|
|
deterministic fingerprint hash of (workflow_id, event_kind, content).
|
|
The comment body includes a hidden HTML marker
|
|
``<!-- controller:fingerprint:abc123 -->``. Before posting, we list
|
|
existing comments + check for the marker; if found, skip the post.
|
|
This gives idempotency without needing the DB to be the source of
|
|
truth.
|
|
|
|
Phase 0 grooming plan (2026-05-24): adds ``close_issue`` and
|
|
``defer_issue`` orchestrators that compose the existing primitives
|
|
above (post_comment / adjust_labels) with the new ``patch_pr_state``
|
|
HTTP primitive AND with DB writes (grooming_decisions audit row +
|
|
controller_event row + workflow state transition). See
|
|
``.drew/regressions-plan.md`` Phase 0. Per decision #45 these live
|
|
here (not in ``forgejo_http.py`` as the plan's literal wording
|
|
suggested) because they are orchestration, not pure HTTP — and
|
|
``forgejo_writes.py`` is already the orchestration home per its own
|
|
docstring.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import logging
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timezone
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
if TYPE_CHECKING:
|
|
from sqlalchemy.orm import Session
|
|
|
|
from ..contracts.causes import Cause
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# Fingerprint marker prefix. Searchable + visible-but-quiet in
|
|
# Forgejo's UI (HTML comments don't render).
|
|
FINGERPRINT_MARKER_PREFIX = "<!-- controller:fingerprint:"
|
|
FINGERPRINT_MARKER_SUFFIX = " -->"
|
|
|
|
|
|
# Callback signatures. Each callback returns "what Forgejo said back"
|
|
# in a normalized shape.
|
|
#
|
|
# list_comments(owner, repo, pr_number) -> list of {"body": str, "id": int, ...}
|
|
# post_comment(owner, repo, pr_number, body) -> {"id": int, "body": str}
|
|
# get_labels(owner, repo, pr_number) -> list of {"name": str, "id": int, ...}
|
|
# add_label(owner, repo, pr_number, label_name) -> True on success
|
|
# remove_label(owner, repo, pr_number, label_name) -> True on success
|
|
ListCommentsCallback = Callable[[str, str, int], list[dict]]
|
|
PostCommentCallback = Callable[[str, str, int, str], dict]
|
|
GetLabelsCallback = Callable[[str, str, int], list[dict]]
|
|
AddLabelCallback = Callable[[str, str, int, str], bool]
|
|
RemoveLabelCallback = Callable[[str, str, int, str], bool]
|
|
# patch_pr_state(owner, repo, pr_number, state) -> {"status": int, "body": dict}.
|
|
# ``state`` is "closed" or "open"; Forgejo treats PRs as issues at this endpoint:
|
|
# PATCH /repos/{owner}/{repo}/issues/{n} body={"state": state}.
|
|
# Phase 0 grooming plan: powers the close path of close_issue.
|
|
PatchPRStateCallback = Callable[[str, str, int, str], dict]
|
|
|
|
|
|
@dataclass
|
|
class StatusCommentResult:
|
|
"""What ``post_status_comment`` returns.
|
|
|
|
Three outcomes:
|
|
- posted: a new comment was successfully posted.
|
|
- duplicate: a comment with the same fingerprint already exists;
|
|
this call was a no-op (idempotency working as intended).
|
|
- failed: the post-comment callback raised; caller decides
|
|
whether to retry on next tick.
|
|
"""
|
|
|
|
status: str # 'posted' | 'duplicate' | 'failed'
|
|
comment_id: int | None = None
|
|
fingerprint: str | None = None
|
|
error: str | None = None
|
|
|
|
|
|
@dataclass
|
|
class LabelAdjustResult:
|
|
"""Summary of one label adjust (set OR unset)."""
|
|
|
|
label: str
|
|
action: str # 'added' | 'removed' | 'no-op' | 'failed'
|
|
error: str | None = None
|
|
|
|
|
|
# ─── fingerprint helpers ──────────────────────────────────────────────
|
|
|
|
|
|
def compute_fingerprint(
|
|
*,
|
|
workflow_id: int,
|
|
event_kind: str,
|
|
content_key: str,
|
|
) -> str:
|
|
"""Build the deterministic fingerprint for a write.
|
|
|
|
The fingerprint is a short SHA256 prefix of
|
|
(workflow_id, event_kind, content_key) — enough to disambiguate
|
|
same-workflow / same-event-kind writes that differ only in content
|
|
(e.g., two STUCK transitions with different reasons).
|
|
"""
|
|
payload = f"{workflow_id}:{event_kind}:{content_key}".encode("utf-8")
|
|
return hashlib.sha256(payload).hexdigest()[:16]
|
|
|
|
|
|
def build_marker(fingerprint: str) -> str:
|
|
"""Build the HTML marker the comment body must include."""
|
|
return f"{FINGERPRINT_MARKER_PREFIX}{fingerprint}{FINGERPRINT_MARKER_SUFFIX}"
|
|
|
|
|
|
def comment_has_fingerprint(comment_body: str, fingerprint: str) -> bool:
|
|
"""Check if a comment's body contains the marker for this fingerprint."""
|
|
return build_marker(fingerprint) in (comment_body or "")
|
|
|
|
|
|
# ─── status comment ───────────────────────────────────────────────────
|
|
|
|
|
|
def post_status_comment(
|
|
*,
|
|
owner: str,
|
|
repo: str,
|
|
pr_number: int,
|
|
workflow_id: int,
|
|
event_kind: str,
|
|
content_key: str,
|
|
body_text: str,
|
|
list_comments: ListCommentsCallback,
|
|
post_comment: PostCommentCallback,
|
|
) -> StatusCommentResult:
|
|
"""Post a status comment with fingerprint-based idempotency.
|
|
|
|
Procedure:
|
|
1. Build the fingerprint from (workflow_id, event_kind, content_key).
|
|
2. Build the marker + check existing comments for it (via
|
|
``list_comments`` callback).
|
|
3. If a matching comment exists → return duplicate (no-op).
|
|
4. Else post a new comment with body = body_text + marker.
|
|
|
|
Failure of ``post_comment`` is returned as ``StatusCommentResult(
|
|
status='failed', error=...)`` — the caller decides whether to
|
|
retry on the next tick. ``list_comments`` failure falls through:
|
|
we conservatively post (risk: occasional duplicate; mitigated by
|
|
fingerprint match on next sweep).
|
|
"""
|
|
fingerprint = compute_fingerprint(
|
|
workflow_id=workflow_id,
|
|
event_kind=event_kind,
|
|
content_key=content_key,
|
|
)
|
|
marker = build_marker(fingerprint)
|
|
|
|
try:
|
|
existing = list_comments(owner, repo, pr_number) or []
|
|
except Exception as exc: # noqa: BLE001 — Forgejo flake; fall through to post
|
|
logger.warning(
|
|
"list_comments failed for #%d (%s); will post (risks duplicate): %s",
|
|
pr_number,
|
|
repo,
|
|
exc,
|
|
)
|
|
existing = []
|
|
|
|
for c in existing:
|
|
if comment_has_fingerprint(c.get("body") or "", fingerprint):
|
|
return StatusCommentResult(
|
|
status="duplicate",
|
|
comment_id=c.get("id"),
|
|
fingerprint=fingerprint,
|
|
)
|
|
|
|
body = f"{body_text}\n\n{marker}"
|
|
try:
|
|
posted = post_comment(owner, repo, pr_number, body)
|
|
except Exception as exc: # noqa: BLE001
|
|
return StatusCommentResult(
|
|
status="failed",
|
|
fingerprint=fingerprint,
|
|
error=str(exc),
|
|
)
|
|
|
|
return StatusCommentResult(
|
|
status="posted",
|
|
comment_id=(posted or {}).get("id"),
|
|
fingerprint=fingerprint,
|
|
)
|
|
|
|
|
|
# ─── labels ───────────────────────────────────────────────────────────
|
|
|
|
|
|
def adjust_labels(
|
|
*,
|
|
owner: str,
|
|
repo: str,
|
|
pr_number: int,
|
|
add: list[str] | None = None,
|
|
remove: list[str] | None = None,
|
|
get_labels: GetLabelsCallback,
|
|
add_label: AddLabelCallback,
|
|
remove_label: RemoveLabelCallback,
|
|
) -> list[LabelAdjustResult]:
|
|
"""Add + remove labels with no-op-on-already-set/missing.
|
|
|
|
Procedure:
|
|
1. List the PR's current labels (via ``get_labels``).
|
|
2. For each label in ``add``: if already present → no-op; else
|
|
add via ``add_label``.
|
|
3. For each label in ``remove``: if not present → no-op; else
|
|
remove via ``remove_label``.
|
|
|
|
Returns one LabelAdjustResult per label across both lists.
|
|
Failures are per-label (a failed add doesn't prevent a remove
|
|
of a different label).
|
|
"""
|
|
add = add or []
|
|
remove = remove or []
|
|
if not add and not remove:
|
|
return []
|
|
|
|
try:
|
|
current_labels = get_labels(owner, repo, pr_number) or []
|
|
except Exception as exc: # noqa: BLE001
|
|
# Defensive: if we can't list, mark every requested action
|
|
# as failed so the caller can retry on next tick.
|
|
err_msg = f"get_labels failed: {exc}"
|
|
return [
|
|
LabelAdjustResult(label=lbl, action="failed", error=err_msg)
|
|
for lbl in (add + remove)
|
|
]
|
|
|
|
current_names = {(c.get("name") or "") for c in current_labels}
|
|
results: list[LabelAdjustResult] = []
|
|
|
|
for lbl in add:
|
|
if lbl in current_names:
|
|
results.append(LabelAdjustResult(label=lbl, action="no-op"))
|
|
continue
|
|
try:
|
|
ok = add_label(owner, repo, pr_number, lbl)
|
|
results.append(
|
|
LabelAdjustResult(
|
|
label=lbl,
|
|
action="added" if ok else "failed",
|
|
error=None if ok else "add_label returned False",
|
|
)
|
|
)
|
|
except Exception as exc: # noqa: BLE001
|
|
results.append(
|
|
LabelAdjustResult(
|
|
label=lbl,
|
|
action="failed",
|
|
error=str(exc),
|
|
)
|
|
)
|
|
|
|
for lbl in remove:
|
|
if lbl not in current_names:
|
|
results.append(LabelAdjustResult(label=lbl, action="no-op"))
|
|
continue
|
|
try:
|
|
ok = remove_label(owner, repo, pr_number, lbl)
|
|
results.append(
|
|
LabelAdjustResult(
|
|
label=lbl,
|
|
action="removed" if ok else "failed",
|
|
error=None if ok else "remove_label returned False",
|
|
)
|
|
)
|
|
except Exception as exc: # noqa: BLE001
|
|
results.append(
|
|
LabelAdjustResult(
|
|
label=lbl,
|
|
action="failed",
|
|
error=str(exc),
|
|
)
|
|
)
|
|
|
|
return results
|
|
|
|
|
|
# ─── Phase 0 grooming: close + defer orchestrators ────────────────────
|
|
|
|
|
|
@dataclass
|
|
class CloseResult:
|
|
"""What ``close_issue`` returns.
|
|
|
|
Status values:
|
|
- ``'closed'`` — Forgejo PATCH returned 200/201; PR is now closed.
|
|
- ``'already-closed'`` — Forgejo PATCH returned 404 (PR already gone);
|
|
treated as no-op success.
|
|
- ``'dry-run'`` — caller passed ``dry_run=True``; audit row written
|
|
with ``executed=0``, no Forgejo call made.
|
|
- ``'pending-retry'`` — transient failure (timeout, 429, 5xx,
|
|
comment-post error). The forgejo_write_pending=1 row remains; a
|
|
later sweep should re-invoke close_issue with the same arguments.
|
|
- ``'failed'`` — non-retryable 4xx (400/401/403/422). Caller should
|
|
surface to the operator; replay_attempts increment is the caller's
|
|
responsibility (Phase 1's retry sweep).
|
|
"""
|
|
|
|
status: str
|
|
decision_id: int | None = None
|
|
fingerprint: str | None = None
|
|
forgejo_status: int | None = None
|
|
error: str | None = None
|
|
|
|
|
|
@dataclass
|
|
class DeferResult:
|
|
"""What ``defer_issue`` returns. Same shape as ``CloseResult``;
|
|
'closed' is replaced by 'deferred', 'already-closed' by
|
|
'already-deferred' (when the audit row + workflow state already
|
|
show this defer has been applied)."""
|
|
|
|
status: str
|
|
decision_id: int | None = None
|
|
fingerprint: str | None = None
|
|
forgejo_status: int | None = None
|
|
error: str | None = None
|
|
|
|
|
|
def _classify_forgejo_status(http_status: int) -> tuple[str, str | None]:
|
|
"""Map an HTTP status to (result_status, error_or_None).
|
|
|
|
Used by both close_issue's PATCH and defer_issue's add/remove-label
|
|
error paths. Centralized so the error-handling matrix from the
|
|
plan is in one place.
|
|
"""
|
|
if http_status in (200, 201):
|
|
return ("ok", None)
|
|
if http_status == 404:
|
|
# Idempotency: already gone is success-no-op.
|
|
return ("ok-no-op", None)
|
|
if http_status == 429:
|
|
return ("pending-retry", "rate-limited")
|
|
if 400 <= http_status < 500:
|
|
return ("failed", f"client error {http_status}")
|
|
if http_status >= 500:
|
|
return ("pending-retry", f"server error {http_status}")
|
|
return ("failed", f"unexpected status {http_status}")
|
|
|
|
|
|
def _now_utc() -> datetime:
|
|
return datetime.now(timezone.utc)
|
|
|
|
|
|
def _assert_outside_txn(session: Session, fn_name: str) -> None:
|
|
"""Phase 0 contract (decision #31): close_issue / defer_issue MUST
|
|
be called outside any existing DB transaction. They manage their
|
|
own atomic transaction for audit-row + event-row + workflow-update
|
|
atomicity. Calling inside an existing txn would tie the orchestrator's
|
|
rollback semantics to the caller's transactional state, defeating the
|
|
crash-safe protocol's atomicity guarantee.
|
|
|
|
Fails loud rather than silently producing weird partial-commit
|
|
behavior.
|
|
"""
|
|
if session.in_transaction():
|
|
raise RuntimeError(
|
|
f"{fn_name} must be called outside any existing transaction; "
|
|
"it manages its own transaction for audit-row + event-row + "
|
|
"workflow-update atomicity."
|
|
)
|
|
|
|
|
|
def close_issue(
|
|
*,
|
|
session: Session,
|
|
owner: str,
|
|
repo: str,
|
|
pr_number: int,
|
|
workflow_id: int,
|
|
# grooming_decisions audit fields
|
|
check_name: str,
|
|
stage: str,
|
|
reason_category: str,
|
|
target_workflow_id: int | None = None,
|
|
confidence: str | None = None,
|
|
llm_reasoning: str | None = None,
|
|
suspicion_score: float | None = None,
|
|
loser_head_sha_at_decision: str | None = None,
|
|
# comment template fields
|
|
gate: str,
|
|
explanation: str,
|
|
# HTTP callbacks
|
|
list_comments: ListCommentsCallback,
|
|
post_comment: PostCommentCallback,
|
|
patch_pr_state: PatchPRStateCallback,
|
|
# Mode
|
|
dry_run: bool = False,
|
|
now: Callable[[], datetime] | None = None,
|
|
# Phase 2 (2026-05-25) — controller_events row attribution.
|
|
# Defaults preserve Phase 1 grooming-close behavior; Phase 2's
|
|
# estimator-abandon path overrides both to write
|
|
# ``cause=Cause.ESTIMATOR_ABANDON`` + ``event_type='estimator_abandon'``.
|
|
# The dedup query that looks up an existing event row for retry
|
|
# also keys on ``event_type``, so the override is what makes Phase
|
|
# 1 and Phase 2 closes for the same workflow correctly tracked as
|
|
# SEPARATE audit-trail entries instead of one squashing the other.
|
|
cause: "Cause | None" = None,
|
|
event_type: str = "grooming_abandon",
|
|
# Private (underscore-prefixed) — public callers SHOULD use
|
|
# ``close_decide_and_act`` (True) or ``close_act`` (False) which
|
|
# set this implicitly. When False, txn 1 SKIPS the workflow-state
|
|
# mutation; the state machine has already applied
|
|
# ``apply_event(GROOMING, groom_verdict_close)`` via ``tick.py``
|
|
# and the workflow is already ABANDONED. When True (Phase 0 /
|
|
# operator path), this function owns the transition. Marked
|
|
# private to nudge callers toward the named wrappers, which can't
|
|
# be confused about which semantic they want.
|
|
_apply_workflow_transition: bool = True,
|
|
) -> CloseResult:
|
|
"""Phase 0 grooming-plan close orchestrator. Implements the
|
|
crash-safe protocol from the plan:
|
|
|
|
1. In one DB transaction: INSERT grooming_decisions audit row +
|
|
INSERT controller_event row (event_type='grooming_abandon',
|
|
cause='grooming_close', forgejo_write_pending=1) + UPDATE
|
|
workflows (current_state='ABANDONED', grooming_evaluated_at).
|
|
The workflow transition inside the txn ensures reconciliation's
|
|
PR-state polling won't write a duplicate 'external-close' event
|
|
(it will see current_state == target_state and short-circuit).
|
|
2. Render the close comment template with decision_id from step 1.
|
|
3. POST audit comment via fingerprint-deduped post_status_comment.
|
|
4. PATCH state:'closed' on Forgejo.
|
|
5. UPDATE controller_event: forgejo_write_pending=0, populate
|
|
forgejo_result; UPDATE grooming_decisions.executed=1.
|
|
|
|
On any failure between steps 1 and 5, the event row stays at
|
|
forgejo_write_pending=1; a later sweep (Phase 1 deliverable) can
|
|
re-invoke ``close_issue`` with the same arguments to resume. The
|
|
audit-row dedup check at the top of this function makes the re-
|
|
invocation skip the txn-1 insert.
|
|
|
|
The plan's "human-closed guard" was DROPPED in decision #41 — Forgejo's
|
|
PR detail response does not include a ``closed_by`` field, so the
|
|
timeline-API alternative was the only path, and the cosmetic-
|
|
misattribution risk on the rare crash-during-retry race was judged
|
|
acceptable.
|
|
"""
|
|
_assert_outside_txn(session, "close_issue")
|
|
|
|
# Avoid module-level model imports to keep forgejo_writes import-time
|
|
# cheap (the rest of the module is dependency-free); also avoids any
|
|
# latent circular-import risk with code paths that import
|
|
# forgejo_writes before db.models is ready.
|
|
from ..contracts.causes import Cause
|
|
from ..db.models import ControllerEvent, GroomingDecision, Workflow
|
|
from sqlalchemy import select
|
|
|
|
# Resolve the cause now that Cause is in scope. Defaulting in the
|
|
# body (vs the signature) avoids the circular-import lift to make
|
|
# Cause a module-top import.
|
|
effective_cause = cause if cause is not None else Cause.GROOMING_CLOSE
|
|
|
|
now_fn = now or _now_utc
|
|
fingerprint = compute_fingerprint(
|
|
workflow_id=workflow_id,
|
|
event_kind="grooming-close",
|
|
content_key=f"close:{check_name}:{stage}:{reason_category}",
|
|
)
|
|
|
|
# Idempotency check + txn 1.
|
|
decision_id: int | None = None
|
|
event_id: int | None = None
|
|
with session.begin():
|
|
# Dedup: a same-fingerprint audit row already exists →
|
|
# this is a retry of a previously-started close. Reuse the
|
|
# decision_id; the txn-1 mutations should already be applied.
|
|
existing = session.execute(
|
|
select(GroomingDecision)
|
|
.where(
|
|
GroomingDecision.workflow_id == workflow_id,
|
|
GroomingDecision.verdict == "close",
|
|
GroomingDecision.check_name == check_name,
|
|
GroomingDecision.reason_category == reason_category,
|
|
)
|
|
.order_by(GroomingDecision.decided_at.desc())
|
|
.limit(1)
|
|
).scalar_one_or_none()
|
|
if existing is not None:
|
|
decision_id = existing.decision_id
|
|
# Find the matching pending event for clear-pending in
|
|
# step 5. Filter on the SAME event_type the original
|
|
# write used — Phase 2's estimator-abandon path writes
|
|
# ``event_type='estimator_abandon'`` so a Phase-1-shaped
|
|
# query for ``'grooming_abandon'`` would miss it and
|
|
# leave the row at ``forgejo_write_pending=1``.
|
|
existing_ev = session.execute(
|
|
select(ControllerEvent)
|
|
.where(
|
|
ControllerEvent.workflow_id == workflow_id,
|
|
ControllerEvent.event_type == event_type,
|
|
ControllerEvent.forgejo_fingerprint == fingerprint,
|
|
)
|
|
.order_by(ControllerEvent.ts.desc())
|
|
.limit(1)
|
|
).scalar_one_or_none()
|
|
if existing_ev is not None:
|
|
event_id = existing_ev.event_id
|
|
else:
|
|
now_val = now_fn()
|
|
row = GroomingDecision(
|
|
workflow_id=workflow_id,
|
|
decided_at=now_val,
|
|
check_name=check_name,
|
|
stage=stage,
|
|
verdict="close",
|
|
reason_category=reason_category,
|
|
target_workflow_id=target_workflow_id,
|
|
confidence=confidence,
|
|
llm_reasoning=llm_reasoning,
|
|
suspicion_score=suspicion_score,
|
|
loser_head_sha_at_decision=loser_head_sha_at_decision,
|
|
executed=0,
|
|
)
|
|
session.add(row)
|
|
session.flush()
|
|
decision_id = row.decision_id
|
|
|
|
wf = session.get(Workflow, workflow_id)
|
|
if wf is None:
|
|
# Surfaces caller bugs immediately.
|
|
raise RuntimeError(
|
|
f"close_issue: workflow {workflow_id} not found in DB"
|
|
)
|
|
ev = ControllerEvent(
|
|
workflow_id=workflow_id,
|
|
ts=now_val,
|
|
event_type=event_type,
|
|
from_state=wf.current_state,
|
|
to_state="ABANDONED",
|
|
cause=effective_cause,
|
|
forgejo_write_pending=True,
|
|
forgejo_fingerprint=fingerprint,
|
|
payload={
|
|
"check_name": check_name,
|
|
"stage": stage,
|
|
"reason_category": reason_category,
|
|
"decision_id": decision_id,
|
|
},
|
|
)
|
|
session.add(ev)
|
|
session.flush()
|
|
event_id = ev.event_id
|
|
|
|
# Workflow transition: → ABANDONED. Direct UPDATE matches
|
|
# the reconciliation pattern (see
|
|
# reconciliation._apply_transition). The Phase 1 corrected
|
|
# dispatch (worker-shape) skips this block via
|
|
# ``apply_workflow_transition=False``: the state machine
|
|
# has already moved the workflow to ABANDONED via
|
|
# ``apply_event(GROOMING, groom_verdict_close)`` before
|
|
# ``run_grooming_side_effects_tick`` calls back into this
|
|
# function. ``grooming_evaluated_at`` is updated either
|
|
# way — it timestamps the grooming decision and is
|
|
# workflow-state-independent.
|
|
if _apply_workflow_transition:
|
|
wf.current_state = "ABANDONED"
|
|
wf.entered_state_at = now_val
|
|
wf.last_transition_at = now_val
|
|
wf.grooming_evaluated_at = now_val
|
|
|
|
# Dry-run short-circuit: txn 1 above wrote the audit row with
|
|
# executed=0; no Forgejo calls.
|
|
if dry_run:
|
|
return CloseResult(
|
|
status="dry-run",
|
|
decision_id=decision_id,
|
|
fingerprint=fingerprint,
|
|
)
|
|
|
|
# Step 2: render the audit comment.
|
|
from .audit_comments import CLOSE_COMMENT_TEMPLATE, render_comment_template
|
|
|
|
filled = CLOSE_COMMENT_TEMPLATE.format(
|
|
gate=gate,
|
|
reason_category=reason_category,
|
|
explanation=explanation,
|
|
canonical_pr_line=(
|
|
f"- Canonical (if duplicate): #{target_workflow_id}"
|
|
if target_workflow_id is not None
|
|
else ""
|
|
),
|
|
confidence_line=(
|
|
f"- LLM confidence (when applicable): {confidence}"
|
|
if confidence is not None
|
|
else ""
|
|
),
|
|
reasoning_line=(
|
|
f"- LLM reasoning (when applicable): {llm_reasoning}"
|
|
if llm_reasoning is not None
|
|
else ""
|
|
),
|
|
)
|
|
if decision_id is None:
|
|
# Defensive: should never happen — either txn 1 inserted or the
|
|
# idempotency branch loaded an existing decision_id.
|
|
raise RuntimeError("close_issue: decision_id is None after txn 1")
|
|
rendered = render_comment_template(filled, decision_id=decision_id)
|
|
|
|
# Step 3: post the comment (fingerprint-deduped — safe to retry).
|
|
comment_result = post_status_comment(
|
|
owner=owner,
|
|
repo=repo,
|
|
pr_number=pr_number,
|
|
workflow_id=workflow_id,
|
|
event_kind="grooming-close",
|
|
content_key=f"close:{check_name}:{stage}:{reason_category}",
|
|
body_text=rendered,
|
|
list_comments=list_comments,
|
|
post_comment=post_comment,
|
|
)
|
|
if comment_result.status == "failed":
|
|
return CloseResult(
|
|
status="pending-retry",
|
|
decision_id=decision_id,
|
|
fingerprint=fingerprint,
|
|
error=f"comment post failed: {comment_result.error}",
|
|
)
|
|
|
|
# Step 4: PATCH state:closed.
|
|
try:
|
|
resp = patch_pr_state(owner, repo, pr_number, "closed")
|
|
except Exception as exc: # noqa: BLE001 — network/timeout: retry next sweep.
|
|
return CloseResult(
|
|
status="pending-retry",
|
|
decision_id=decision_id,
|
|
fingerprint=fingerprint,
|
|
error=f"patch_pr_state raised: {exc}",
|
|
)
|
|
forgejo_status = int((resp or {}).get("status") or 0)
|
|
category, err = _classify_forgejo_status(forgejo_status)
|
|
if category == "pending-retry":
|
|
return CloseResult(
|
|
status="pending-retry",
|
|
decision_id=decision_id,
|
|
fingerprint=fingerprint,
|
|
forgejo_status=forgejo_status,
|
|
error=err,
|
|
)
|
|
if category == "failed":
|
|
return CloseResult(
|
|
status="failed",
|
|
decision_id=decision_id,
|
|
fingerprint=fingerprint,
|
|
forgejo_status=forgejo_status,
|
|
error=err or f"patch returned {forgejo_status}: {(resp or {}).get('body')}",
|
|
)
|
|
# category == 'ok' or 'ok-no-op'
|
|
result_status = "already-closed" if category == "ok-no-op" else "closed"
|
|
|
|
# Step 5: clear forgejo_write_pending + mark executed.
|
|
if event_id is not None:
|
|
with session.begin():
|
|
ev = session.get(ControllerEvent, event_id)
|
|
if ev is not None:
|
|
ev.forgejo_write_pending = False
|
|
ev.forgejo_result = {
|
|
"patch_status": forgejo_status,
|
|
"comment_status": comment_result.status,
|
|
"comment_id": comment_result.comment_id,
|
|
}
|
|
gd = session.get(GroomingDecision, decision_id)
|
|
if gd is not None:
|
|
gd.executed = 1
|
|
gd.forgejo_response = {
|
|
"patch_status": forgejo_status,
|
|
"comment_status": comment_result.status,
|
|
}
|
|
|
|
return CloseResult(
|
|
status=result_status,
|
|
decision_id=decision_id,
|
|
fingerprint=fingerprint,
|
|
forgejo_status=forgejo_status,
|
|
)
|
|
|
|
|
|
def defer_issue(
|
|
*,
|
|
session: Session,
|
|
owner: str,
|
|
repo: str,
|
|
pr_number: int,
|
|
workflow_id: int,
|
|
# grooming_decisions audit fields
|
|
check_name: str,
|
|
stage: str,
|
|
reason_category: str,
|
|
target_workflow_id: int | None = None,
|
|
confidence: str | None = None,
|
|
llm_reasoning: str | None = None,
|
|
preserved_value: str | None = None,
|
|
suspicion_score: float | None = None,
|
|
loser_head_sha_at_decision: str | None = None,
|
|
# comment template fields
|
|
gate: str,
|
|
canonical_pr_number: int | None = None,
|
|
# defer-specific
|
|
deferred_reason: str = "duplication",
|
|
new_label: str = "auto/needs-reevaluation",
|
|
remove_label: str = "auto/sentinel",
|
|
# HTTP callbacks
|
|
list_comments: ListCommentsCallback,
|
|
post_comment: PostCommentCallback,
|
|
get_labels: GetLabelsCallback,
|
|
add_label: AddLabelCallback,
|
|
remove_label_cb: RemoveLabelCallback,
|
|
# Mode
|
|
dry_run: bool = False,
|
|
now: Callable[[], datetime] | None = None,
|
|
# Private — public callers SHOULD use ``defer_decide_and_act``
|
|
# (True) or ``defer_act`` (False); see ``close_issue``'s same-named
|
|
# kwarg for full semantics. When False, the workflow-state
|
|
# mutation + ``pre_pause_state`` capture are SKIPPED (state
|
|
# machine fired ``groom_verdict_defer`` → PAUSED already);
|
|
# ``deferred_reason`` / ``deferred_at`` / ``deferred_target_workflow_id``
|
|
# columns are written either way (decision context, not state).
|
|
_apply_workflow_transition: bool = True,
|
|
) -> DeferResult:
|
|
"""Phase 0 grooming-plan defer orchestrator (state-based dedup
|
|
pattern — decisions #22 + #42).
|
|
|
|
Procedure (one txn for state + audit + event, HTTP work after,
|
|
one txn for clear-pending):
|
|
|
|
1. In one DB transaction:
|
|
- INSERT grooming_decisions audit row.
|
|
- INSERT controller_event row with event_type='label-pause',
|
|
cause='grooming_defer', forgejo_write_pending=1.
|
|
- UPDATE workflows: current_state='PAUSED' (with pre_pause_state
|
|
capture), deferred_reason / deferred_at / deferred_target_workflow_id,
|
|
grooming_evaluated_at.
|
|
The state transition inside the txn ensures reconciliation's
|
|
pause-detection clause (`current != 'PAUSED'`) skips on the next
|
|
tick, AND the RESUME guard (Phase 1 will add ``and
|
|
deferred_reason IS NULL``) prevents un-pause during the pre-PATCH
|
|
window where Forgejo still shows the label.
|
|
2. Render the defer comment template with decision_id.
|
|
3. POST audit comment.
|
|
4. Remove auto/sentinel + add auto/needs-reevaluation on Forgejo.
|
|
5. UPDATE controller_event: forgejo_write_pending=0; UPDATE
|
|
grooming_decisions.executed=1.
|
|
|
|
Scheduler skips deferred workflows via the same ``deferred_reason
|
|
IS NOT NULL`` filter Phase 1 will add to scheduler.py — even if
|
|
auto/sentinel is re-added later. Resume requires both the
|
|
deferred_reason clear AND the label restore (the two AND-gated
|
|
blocks design).
|
|
"""
|
|
_assert_outside_txn(session, "defer_issue")
|
|
|
|
from ..contracts.causes import Cause
|
|
from ..db.models import ControllerEvent, GroomingDecision, Workflow
|
|
from sqlalchemy import select
|
|
|
|
now_fn = now or _now_utc
|
|
fingerprint = compute_fingerprint(
|
|
workflow_id=workflow_id,
|
|
event_kind="grooming-defer",
|
|
content_key=f"defer:{check_name}:{stage}:{reason_category}",
|
|
)
|
|
|
|
decision_id: int | None = None
|
|
event_id: int | None = None
|
|
with session.begin():
|
|
# Idempotency: existing defer audit row for same fingerprint →
|
|
# this is a retry, reuse the decision_id; state transition
|
|
# already happened on the original call.
|
|
existing = session.execute(
|
|
select(GroomingDecision)
|
|
.where(
|
|
GroomingDecision.workflow_id == workflow_id,
|
|
GroomingDecision.verdict == "defer",
|
|
GroomingDecision.check_name == check_name,
|
|
GroomingDecision.reason_category == reason_category,
|
|
)
|
|
.order_by(GroomingDecision.decided_at.desc())
|
|
.limit(1)
|
|
).scalar_one_or_none()
|
|
if existing is not None:
|
|
decision_id = existing.decision_id
|
|
existing_ev = session.execute(
|
|
select(ControllerEvent)
|
|
.where(
|
|
ControllerEvent.workflow_id == workflow_id,
|
|
ControllerEvent.event_type == "label-pause",
|
|
ControllerEvent.forgejo_fingerprint == fingerprint,
|
|
)
|
|
.order_by(ControllerEvent.ts.desc())
|
|
.limit(1)
|
|
).scalar_one_or_none()
|
|
if existing_ev is not None:
|
|
event_id = existing_ev.event_id
|
|
else:
|
|
now_val = now_fn()
|
|
row = GroomingDecision(
|
|
workflow_id=workflow_id,
|
|
decided_at=now_val,
|
|
check_name=check_name,
|
|
stage=stage,
|
|
verdict="defer",
|
|
reason_category=reason_category,
|
|
target_workflow_id=target_workflow_id,
|
|
confidence=confidence,
|
|
llm_reasoning=llm_reasoning,
|
|
preserved_value=preserved_value,
|
|
suspicion_score=suspicion_score,
|
|
loser_head_sha_at_decision=loser_head_sha_at_decision,
|
|
executed=0,
|
|
)
|
|
session.add(row)
|
|
session.flush()
|
|
decision_id = row.decision_id
|
|
|
|
wf = session.get(Workflow, workflow_id)
|
|
if wf is None:
|
|
raise RuntimeError(
|
|
f"defer_issue: workflow {workflow_id} not found in DB"
|
|
)
|
|
|
|
ev = ControllerEvent(
|
|
workflow_id=workflow_id,
|
|
ts=now_val,
|
|
event_type="label-pause",
|
|
from_state=wf.current_state,
|
|
to_state="PAUSED",
|
|
cause=Cause.GROOMING_DEFER,
|
|
forgejo_write_pending=True,
|
|
forgejo_fingerprint=fingerprint,
|
|
payload={
|
|
"check_name": check_name,
|
|
"stage": stage,
|
|
"reason_category": reason_category,
|
|
"decision_id": decision_id,
|
|
"deferred_reason": deferred_reason,
|
|
},
|
|
)
|
|
session.add(ev)
|
|
session.flush()
|
|
event_id = ev.event_id
|
|
|
|
# Capture pre_pause_state ONLY if the workflow isn't
|
|
# already paused — preserves the original pause origin if a
|
|
# human had already pulled the label. The Phase 1
|
|
# corrected dispatch skips the state mutation (state
|
|
# machine already transitioned to PAUSED via
|
|
# ``groom_verdict_defer``); the decision-context columns
|
|
# (deferred_reason / deferred_at / target /
|
|
# grooming_evaluated_at) are written either way.
|
|
if _apply_workflow_transition:
|
|
if wf.current_state != "PAUSED":
|
|
wf.pre_pause_state = wf.current_state
|
|
wf.current_state = "PAUSED"
|
|
wf.entered_state_at = now_val
|
|
wf.last_transition_at = now_val
|
|
wf.grooming_evaluated_at = now_val
|
|
wf.deferred_reason = deferred_reason
|
|
wf.deferred_at = now_val
|
|
wf.deferred_target_workflow_id = target_workflow_id
|
|
|
|
if dry_run:
|
|
return DeferResult(
|
|
status="dry-run",
|
|
decision_id=decision_id,
|
|
fingerprint=fingerprint,
|
|
)
|
|
|
|
# Step 2: render comment.
|
|
from .audit_comments import DEFER_COMMENT_TEMPLATE, render_comment_template
|
|
|
|
if decision_id is None:
|
|
raise RuntimeError("defer_issue: decision_id is None after txn 1")
|
|
|
|
filled = DEFER_COMMENT_TEMPLATE.format(
|
|
gate=gate,
|
|
reason_category=reason_category,
|
|
canonical_pr_number=(
|
|
canonical_pr_number if canonical_pr_number is not None else "-"
|
|
),
|
|
confidence=confidence if confidence is not None else "-",
|
|
reasoning=llm_reasoning if llm_reasoning is not None else "-",
|
|
preserved_value_line=(
|
|
f"- Preserved value (when applicable): {preserved_value}"
|
|
if preserved_value is not None
|
|
else ""
|
|
),
|
|
workflow_id=workflow_id,
|
|
)
|
|
rendered = render_comment_template(filled, decision_id=decision_id)
|
|
|
|
# Step 3: post comment.
|
|
comment_result = post_status_comment(
|
|
owner=owner,
|
|
repo=repo,
|
|
pr_number=pr_number,
|
|
workflow_id=workflow_id,
|
|
event_kind="grooming-defer",
|
|
content_key=f"defer:{check_name}:{stage}:{reason_category}",
|
|
body_text=rendered,
|
|
list_comments=list_comments,
|
|
post_comment=post_comment,
|
|
)
|
|
if comment_result.status == "failed":
|
|
return DeferResult(
|
|
status="pending-retry",
|
|
decision_id=decision_id,
|
|
fingerprint=fingerprint,
|
|
error=f"comment post failed: {comment_result.error}",
|
|
)
|
|
|
|
# Step 4: label swap. adjust_labels handles no-op cases (already
|
|
# present/missing). Per-label failures are surfaced so we can
|
|
# pending-retry if either side failed.
|
|
label_results = adjust_labels(
|
|
owner=owner,
|
|
repo=repo,
|
|
pr_number=pr_number,
|
|
add=[new_label],
|
|
remove=[remove_label],
|
|
get_labels=get_labels,
|
|
add_label=add_label,
|
|
remove_label=remove_label_cb,
|
|
)
|
|
failed_labels = [r for r in label_results if r.action == "failed"]
|
|
if failed_labels:
|
|
# Don't clear pending; let a sweep retry.
|
|
return DeferResult(
|
|
status="pending-retry",
|
|
decision_id=decision_id,
|
|
fingerprint=fingerprint,
|
|
error=(
|
|
"label swap had failures: "
|
|
+ ", ".join(f"{r.label}:{r.error}" for r in failed_labels)
|
|
),
|
|
)
|
|
|
|
# Step 5: clear forgejo_write_pending + mark executed.
|
|
if event_id is not None:
|
|
with session.begin():
|
|
ev = session.get(ControllerEvent, event_id)
|
|
if ev is not None:
|
|
ev.forgejo_write_pending = False
|
|
ev.forgejo_result = {
|
|
"comment_status": comment_result.status,
|
|
"comment_id": comment_result.comment_id,
|
|
"label_actions": [
|
|
{"label": r.label, "action": r.action}
|
|
for r in label_results
|
|
],
|
|
}
|
|
gd = session.get(GroomingDecision, decision_id)
|
|
if gd is not None:
|
|
gd.executed = 1
|
|
gd.forgejo_response = {
|
|
"comment_status": comment_result.status,
|
|
"label_actions": [
|
|
{"label": r.label, "action": r.action}
|
|
for r in label_results
|
|
],
|
|
}
|
|
|
|
return DeferResult(
|
|
status="deferred",
|
|
decision_id=decision_id,
|
|
fingerprint=fingerprint,
|
|
)
|
|
|
|
|
|
# Phase 1 corrected dispatch (2026-05-25) — explicit named entry-points
|
|
# for the two callers of the same underlying orchestrator. Each public
|
|
# function has a full keyword-only signature (no **kwargs) so IDE
|
|
# type-checking + introspection work the same as on ``close_issue`` /
|
|
# ``defer_issue`` themselves.
|
|
#
|
|
# close_decide_and_act / defer_decide_and_act
|
|
# Phase 0 semantics: decide (open the txn, write audit row +
|
|
# event row, mutate workflow state) AND act (Forgejo writes,
|
|
# clear pending). Use when the call site is the sole owner of
|
|
# the state transition — operator scripts, the legacy
|
|
# synchronous path. Aliases to ``close_issue`` / ``defer_issue``
|
|
# for back-compat with the Phase 0 test suite.
|
|
#
|
|
# close_act / defer_act
|
|
# Worker-shape semantics: the state machine already fired the
|
|
# verdict event (``groom_verdict_close`` / ``groom_verdict_defer``)
|
|
# and transitioned the workflow. This variant writes the audit
|
|
# row + event row + Forgejo writes WITHOUT re-mutating workflow
|
|
# state. Used by ``run_grooming_side_effects_tick``.
|
|
#
|
|
# Both pairs delegate to the shared ``close_issue`` / ``defer_issue``
|
|
# bodies via the private ``_apply_workflow_transition`` kwarg. The
|
|
# bool itself is module-private; callers SHOULD pick the named entry-
|
|
# point that matches their intent and let the wrapper set the flag.
|
|
# The architect's Phase-1 review noted that a future refactor should
|
|
# split the shared body into truly separate primitives (see deferred
|
|
# backlog in ``.drew/regressions-plan.md``); this interim shape avoids
|
|
# that work while still giving the two semantics distinct public names.
|
|
close_decide_and_act = close_issue
|
|
defer_decide_and_act = defer_issue
|
|
|
|
|
|
def close_act(
|
|
*,
|
|
session: Session,
|
|
owner: str,
|
|
repo: str,
|
|
pr_number: int,
|
|
workflow_id: int,
|
|
check_name: str,
|
|
stage: str,
|
|
reason_category: str,
|
|
target_workflow_id: int | None = None,
|
|
confidence: str | None = None,
|
|
llm_reasoning: str | None = None,
|
|
suspicion_score: float | None = None,
|
|
loser_head_sha_at_decision: str | None = None,
|
|
gate: str,
|
|
explanation: str,
|
|
list_comments: ListCommentsCallback,
|
|
post_comment: PostCommentCallback,
|
|
patch_pr_state: PatchPRStateCallback,
|
|
dry_run: bool = False,
|
|
now: Callable[[], datetime] | None = None,
|
|
cause: "Cause | None" = None,
|
|
event_type: str = "grooming_abandon",
|
|
) -> CloseResult:
|
|
"""Phase 1 worker-shape close: Forgejo writes + audit row only;
|
|
the workflow-state transition has already been applied by the
|
|
state machine via ``apply_event(GROOMING, groom_verdict_close)``.
|
|
|
|
Signature mirrors ``close_issue`` exactly EXCEPT no
|
|
``_apply_workflow_transition`` parameter — this entry-point pins
|
|
it to False. Use ``close_decide_and_act`` when your caller is the
|
|
sole owner of the state transition (operator scripts, Phase 0
|
|
legacy path).
|
|
|
|
Phase 2 callers (estimator-abandon) MUST pass
|
|
``cause=Cause.ESTIMATOR_ABANDON`` + ``event_type='estimator_abandon'``
|
|
so the audit-trail attribution is correct; Phase 1 grooming callers
|
|
accept the defaults (cause=Cause.GROOMING_CLOSE,
|
|
event_type='grooming_abandon').
|
|
"""
|
|
return close_issue(
|
|
session=session,
|
|
owner=owner,
|
|
repo=repo,
|
|
pr_number=pr_number,
|
|
workflow_id=workflow_id,
|
|
check_name=check_name,
|
|
stage=stage,
|
|
reason_category=reason_category,
|
|
target_workflow_id=target_workflow_id,
|
|
confidence=confidence,
|
|
llm_reasoning=llm_reasoning,
|
|
suspicion_score=suspicion_score,
|
|
loser_head_sha_at_decision=loser_head_sha_at_decision,
|
|
gate=gate,
|
|
explanation=explanation,
|
|
list_comments=list_comments,
|
|
post_comment=post_comment,
|
|
patch_pr_state=patch_pr_state,
|
|
dry_run=dry_run,
|
|
now=now,
|
|
cause=cause,
|
|
event_type=event_type,
|
|
_apply_workflow_transition=False,
|
|
)
|
|
|
|
|
|
def defer_act(
|
|
*,
|
|
session: Session,
|
|
owner: str,
|
|
repo: str,
|
|
pr_number: int,
|
|
workflow_id: int,
|
|
check_name: str,
|
|
stage: str,
|
|
reason_category: str,
|
|
target_workflow_id: int | None = None,
|
|
confidence: str | None = None,
|
|
llm_reasoning: str | None = None,
|
|
preserved_value: str | None = None,
|
|
suspicion_score: float | None = None,
|
|
loser_head_sha_at_decision: str | None = None,
|
|
gate: str,
|
|
canonical_pr_number: int | None = None,
|
|
deferred_reason: str = "duplication",
|
|
new_label: str = "auto/needs-reevaluation",
|
|
remove_label: str = "auto/sentinel",
|
|
list_comments: ListCommentsCallback,
|
|
post_comment: PostCommentCallback,
|
|
get_labels: GetLabelsCallback,
|
|
add_label: AddLabelCallback,
|
|
remove_label_cb: RemoveLabelCallback,
|
|
dry_run: bool = False,
|
|
now: Callable[[], datetime] | None = None,
|
|
) -> DeferResult:
|
|
"""Phase 1 worker-shape defer: Forgejo writes + audit row only;
|
|
the workflow-state transition has already been applied by the
|
|
state machine via ``apply_event(GROOMING, groom_verdict_defer)``.
|
|
|
|
Signature mirrors ``defer_issue`` exactly EXCEPT no
|
|
``_apply_workflow_transition`` parameter — this entry-point pins
|
|
it to False. Use ``defer_decide_and_act`` when your caller owns
|
|
the state transition (operator scripts, Phase 0 legacy path).
|
|
"""
|
|
return defer_issue(
|
|
session=session,
|
|
owner=owner,
|
|
repo=repo,
|
|
pr_number=pr_number,
|
|
workflow_id=workflow_id,
|
|
check_name=check_name,
|
|
stage=stage,
|
|
reason_category=reason_category,
|
|
target_workflow_id=target_workflow_id,
|
|
confidence=confidence,
|
|
llm_reasoning=llm_reasoning,
|
|
preserved_value=preserved_value,
|
|
suspicion_score=suspicion_score,
|
|
loser_head_sha_at_decision=loser_head_sha_at_decision,
|
|
gate=gate,
|
|
canonical_pr_number=canonical_pr_number,
|
|
deferred_reason=deferred_reason,
|
|
new_label=new_label,
|
|
remove_label=remove_label,
|
|
list_comments=list_comments,
|
|
post_comment=post_comment,
|
|
get_labels=get_labels,
|
|
add_label=add_label,
|
|
remove_label_cb=remove_label_cb,
|
|
dry_run=dry_run,
|
|
now=now,
|
|
_apply_workflow_transition=False,
|
|
)
|
|
|
|
|
|
__all__ = [
|
|
"AddLabelCallback",
|
|
"CloseResult",
|
|
"DeferResult",
|
|
"FINGERPRINT_MARKER_PREFIX",
|
|
"FINGERPRINT_MARKER_SUFFIX",
|
|
"GetLabelsCallback",
|
|
"LabelAdjustResult",
|
|
"ListCommentsCallback",
|
|
"PatchPRStateCallback",
|
|
"PostCommentCallback",
|
|
"RemoveLabelCallback",
|
|
"StatusCommentResult",
|
|
"adjust_labels",
|
|
"build_marker",
|
|
"close_act",
|
|
"close_decide_and_act",
|
|
"close_issue",
|
|
"comment_has_fingerprint",
|
|
"compute_fingerprint",
|
|
"defer_act",
|
|
"defer_decide_and_act",
|
|
"defer_issue",
|
|
"post_status_comment",
|
|
]
|