Skip to content

Commit 0926d73

Browse files
authored
fix: add pr264 projection repair contract (#572)
* fix: add pr264 projection repair contract * fix: harden pr264 projection repair contract * test: align reviewer-bot projection contract fixtures * fix: harden projection repair workflow guards * fix: preserve projection write approval authority
1 parent 3dcab74 commit 0926d73

27 files changed

Lines changed: 2964 additions & 109 deletions

.github/workflows/reviewer-bot-preview.yml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ on:
1010
required: true
1111
default: preview-check-overdue
1212
type: choice
13-
options: [preview-check-overdue, preview-reviewer-board]
13+
options: [preview-check-overdue, preview-status-label-projection, preview-issue314-state-health, preview-reviewer-board]
1414
issue_number:
1515
description: PR or issue number to preview
1616
required: true
@@ -56,6 +56,11 @@ jobs:
5656
REPO_NAME: ${{ github.event.repository.name }}
5757
GITHUB_REPOSITORY: ${{ github.repository }}
5858
GITHUB_REF: ${{ github.ref }}
59+
EVALUATED_REPO: ${{ github.repository }}
60+
HEAD_SHA: ${{ github.sha }}
61+
EVALUATED_REF: ${{ github.sha }}
62+
GITHUB_RUN_ID: ${{ github.run_id }}
63+
GITHUB_RUN_ATTEMPT: ${{ github.run_attempt }}
5964
WORKFLOW_RUN_ID: ${{ github.run_id }}
6065
WORKFLOW_NAME: ${{ github.workflow }}
6166
WORKFLOW_JOB_NAME: ${{ github.job }}

.github/workflows/reviewer-bot-sweeper-repair.yml

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
name: Reviewer Bot Sweeper Repair
22

3+
run-name: repair ${{ github.event.inputs.action }} issue ${{ github.event.inputs.issue_number }} nonce ${{ github.event.inputs.validation_nonce }}
4+
35
on:
46
schedule:
57
- cron: '0 * * * *'
@@ -10,11 +12,15 @@ on:
1012
required: true
1113
default: check-overdue
1214
type: choice
13-
options: [sync-members, show-state, check-overdue, repair-review-status-labels]
15+
options: [sync-members, show-state, check-overdue, repair-review-status-labels, repair-issue314-state-health]
1416
issue_number:
1517
description: Optional issue or PR number for targeted actions
1618
required: false
1719
type: string
20+
validation_nonce:
21+
description: Validation nonce used to correlate this repair run
22+
required: true
23+
type: string
1824

1925
permissions:
2026
contents: read
@@ -49,14 +55,32 @@ jobs:
4955
EVENT_ACTION: ${{ github.event.action }}
5056
MANUAL_ACTION: ${{ github.event.inputs.action }}
5157
ISSUE_NUMBER: ${{ github.event.inputs.issue_number }}
58+
VALIDATION_NONCE: ${{ github.event.inputs.validation_nonce }}
5259
REPO_OWNER: ${{ github.repository_owner }}
5360
REPO_NAME: ${{ github.event.repository.name }}
5461
GITHUB_REPOSITORY: ${{ github.repository }}
5562
GITHUB_REF: ${{ github.ref }}
63+
EVALUATED_REPO: ${{ github.repository }}
64+
HEAD_SHA: ${{ github.sha }}
65+
EVALUATED_REF: ${{ github.sha }}
66+
GITHUB_RUN_ID: ${{ github.run_id }}
67+
GITHUB_RUN_ATTEMPT: ${{ github.run_attempt }}
5668
WORKFLOW_RUN_ID: ${{ github.run_id }}
5769
WORKFLOW_NAME: ${{ github.workflow }}
5870
WORKFLOW_JOB_NAME: ${{ github.job }}
59-
run: uv run --project "$BOT_SRC_ROOT" reviewer-bot
71+
REPAIR_SUMMARY_PATH: ${{ runner.temp }}/reviewer-bot-repair-output-${{ github.run_id }}-attempt-${{ github.run_attempt }}/repair-summary.json
72+
ISSUE314_STATE_HEALTH_REPAIR_SUMMARY_PATH: ${{ runner.temp }}/reviewer-bot-repair-output-${{ github.run_id }}-attempt-${{ github.run_attempt }}/issue314-state-health-repair-summary.json
73+
run: |
74+
mkdir -p "$RUNNER_TEMP/reviewer-bot-repair-output-${GITHUB_RUN_ID}-attempt-${GITHUB_RUN_ATTEMPT}"
75+
uv run --project "$BOT_SRC_ROOT" reviewer-bot
76+
- name: Upload repair artifact
77+
if: ${{ github.event_name == 'workflow_dispatch' && (github.event.inputs.action == 'repair-review-status-labels' || github.event.inputs.action == 'repair-issue314-state-health') }}
78+
uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808
79+
with:
80+
name: reviewer-bot-repair-output-${{ github.run_id }}-attempt-${{ github.run_attempt }}
81+
path: ${{ runner.temp }}/reviewer-bot-repair-output-${{ github.run_id }}-attempt-${{ github.run_attempt }}
82+
retention-days: 7
83+
if-no-files-found: error
6084
- name: Workflow summary
6185
run: |
6286
{

scripts/reviewer_bot_core/approval_policy.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -415,8 +415,6 @@ def compute_pr_approval_state_result(
415415
permission_evidence=permission_evidence,
416416
dismissal_evidence=None,
417417
)
418-
if write_decision.response_state == "projection_failed":
419-
return live_review_support.projection_failure_result(write_decision.diagnostic_reason or write_decision.write_approval_state)
420418
completion = {
421419
"completed": completion_decision.can_set_review_completed_at,
422420
"current_head_sha": current_head,
@@ -425,6 +423,20 @@ def compute_pr_approval_state_result(
425423
),
426424
"authority_decision": completion_decision.to_output(),
427425
}
426+
if write_decision.response_state == "projection_failed":
427+
result = live_review_support.projection_failure_result(
428+
write_decision.diagnostic_reason or write_decision.write_approval_state
429+
)
430+
result["completion"] = completion
431+
result["write_approval"] = {
432+
"has_write_approval": False,
433+
"write_approvers": [],
434+
"current_head_sha": current_head,
435+
"response_state": write_decision.response_state,
436+
"authority_decision": write_decision.to_output(),
437+
}
438+
result["current_head_sha"] = current_head
439+
return result
428440
write_approval = {
429441
"has_write_approval": write_decision.response_state == "done",
430442
"write_approvers": [write_decision.approving_reviewer] if write_decision.response_state == "done" and write_decision.approving_reviewer else [],

scripts/reviewer_bot_core/reviewer_response_policy.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
from __future__ import annotations
1616

17-
from dataclasses import dataclass
17+
from dataclasses import dataclass, replace
1818
from datetime import datetime, timezone
1919

2020
from . import live_review_support, reviewer_review_helpers
@@ -173,11 +173,16 @@ def apply_reminder_cadence_overlay(response: ReviewerResponseDecision, cadence)
173173
if response.response_state != "awaiting_reviewer_response":
174174
return response
175175
reason = getattr(cadence, "exhaustion_reason", None) or "legacy_duplicate_reminders_exhausted"
176+
scope = (
177+
replace(response.scope, scope_basis="reminder_cadence_exhausted")
178+
if response.scope is not None
179+
else None
180+
)
176181
return ReviewerResponseDecision(
177182
response_state="reviewer_reassignment_needed",
178183
reason=reason,
179184
suppression_reason=reason,
180-
scope=response.scope,
185+
scope=scope,
181186
current_head_sha=response.current_head_sha,
182187
anchor_timestamp=response.anchor_timestamp,
183188
reviewer_authority_outcome=response.reviewer_authority_outcome,
@@ -470,6 +475,13 @@ def _decorate_response(
470475
}
471476

472477

478+
def _write_approval_authority_payload(write_approval: object) -> dict[str, object] | None:
479+
if not isinstance(write_approval, dict):
480+
return None
481+
authority = write_approval.get("authority_decision")
482+
return dict(authority) if isinstance(authority, dict) else None
483+
484+
473485
def _record_for_current_reviewer(record: dict | None | object, current_reviewer: str) -> dict | None:
474486
if not isinstance(record, dict):
475487
return None
@@ -872,14 +884,19 @@ def derive_reviewer_response_state(
872884
)
873885

874886
if not isinstance(approval_result, dict) or not approval_result.get("ok"):
887+
write_approval_authority = _write_approval_authority_payload(
888+
approval_result.get("write_approval") if isinstance(approval_result, dict) else None
889+
)
875890
return _decorate_response(
876891
state="projection_failed",
877892
reason="live_review_state_unknown",
878893
scope_fields=_current_scope_fields(review_data, current_reviewer, current_head, contributor_handoff),
894+
write_approval_authority=write_approval_authority,
879895
)
880896

881897
completion = approval_result["completion"]
882898
write_approval = approval_result["write_approval"]
899+
write_approval_authority = _write_approval_authority_payload(write_approval)
883900
if not completion.get("completed"):
884901
return _decorate_response(
885902
state="awaiting_contributor_response",
@@ -906,6 +923,7 @@ def derive_reviewer_response_state(
906923
current_cycle_reviewer_handoff=reviewer_handoff,
907924
contributor_comment=contributor_comment,
908925
contributor_handoff=contributor_handoff,
926+
write_approval_authority=write_approval_authority,
909927
)
910928
if authority_response_state == "projection_failed":
911929
authority = write_approval.get("authority_decision")
@@ -916,6 +934,7 @@ def derive_reviewer_response_state(
916934
state="projection_failed",
917935
reason=reason,
918936
scope_fields=_current_scope_fields(review_data, current_reviewer, current_head, contributor_handoff),
937+
write_approval_authority=write_approval_authority,
919938
)
920939
if not write_approval.get("has_write_approval"):
921940
return _decorate_response(
@@ -929,6 +948,7 @@ def derive_reviewer_response_state(
929948
current_cycle_reviewer_handoff=reviewer_handoff,
930949
contributor_comment=contributor_comment,
931950
contributor_handoff=contributor_handoff,
951+
write_approval_authority=write_approval_authority,
932952
)
933953
return _decorate_response(
934954
state="done",
@@ -941,6 +961,7 @@ def derive_reviewer_response_state(
941961
current_cycle_reviewer_handoff=reviewer_handoff,
942962
contributor_comment=contributor_comment,
943963
contributor_handoff=contributor_handoff,
964+
write_approval_authority=write_approval_authority,
944965
)
945966

946967

scripts/reviewer_bot_lib/app.py

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from dataclasses import dataclass
77

88
from . import maintenance, reconcile
9-
from .context import EventContext, ExecutionResult
9+
from .context import EventContext, ExecutionResult, ManualDispatchRequest
1010
from .event_inputs import build_event_context as decode_event_context
1111
from .maintenance import (
1212
collect_status_projection_repair_items,
@@ -249,7 +249,13 @@ def _classify_event_intent_from_context(bot: AppEventContextRuntime, context: Ev
249249
return bot.EVENT_INTENT_NON_MUTATING_READONLY
250250

251251
if event_name == "workflow_dispatch":
252-
if context.manual_action in {"show-state", "preview-check-overdue", "preview-reviewer-board"}:
252+
if context.manual_action in {
253+
"show-state",
254+
"preview-check-overdue",
255+
"preview-status-label-projection",
256+
"preview-issue314-state-health",
257+
"preview-reviewer-board",
258+
}:
253259
return bot.EVENT_INTENT_NON_MUTATING_READONLY
254260
return bot.EVENT_INTENT_MUTATING
255261

@@ -316,6 +322,7 @@ def execute_run(bot: AppExecutionRuntime, context: EventContext) -> ExecutionRes
316322
workflow_run_result: reconcile.WorkflowRunHandlerResult | None = None
317323
schedule_result: maintenance.ScheduleHandlerResult | None = None
318324
loaded_state_hash: str | None = None
325+
manual_projection_policy: maintenance.ManualDispatchProjectionPolicy | None = None
319326

320327
try:
321328
if (
@@ -352,6 +359,13 @@ def execute_run(bot: AppExecutionRuntime, context: EventContext) -> ExecutionRes
352359
if isinstance(issue_key, str) and issue_key.isdigit()
353360
}
354361
loaded_epoch = state.get("freshness_runtime_epoch") if isinstance(state.get("freshness_runtime_epoch"), str) else None
362+
if event_name == "workflow_dispatch":
363+
manual_projection_policy = maintenance.derive_manual_dispatch_projection_policy(
364+
ManualDispatchRequest(
365+
action=context.manual_action or "",
366+
issue_number=context.issue_number,
367+
)
368+
)
355369

356370
if lock_required:
357371
state, restored = bot.adapters.workflow.process_pass_until_expirations(state)
@@ -442,7 +456,17 @@ def execute_run(bot: AppExecutionRuntime, context: EventContext) -> ExecutionRes
442456
touched_items = schedule_result.touched_items
443457
else:
444458
touched_items = bot.drain_touched_items()
445-
if lock_required and event_name in {"schedule", "workflow_dispatch"} and status_projection_repair_needed(bot, state):
459+
allow_epoch_repair_expansion = (
460+
manual_projection_policy.allow_epoch_repair_expansion
461+
if manual_projection_policy is not None
462+
else True
463+
)
464+
if (
465+
lock_required
466+
and event_name in {"schedule", "workflow_dispatch"}
467+
and allow_epoch_repair_expansion
468+
and status_projection_repair_needed(bot, state)
469+
):
446470
touched_items = sorted(
447471
{
448472
*touched_items,
@@ -534,6 +558,8 @@ def execute_run(bot: AppExecutionRuntime, context: EventContext) -> ExecutionRes
534558
if touched_items:
535559
state = bot.state_store.load_state(fail_on_unavailable=True)
536560

561+
maintenance.emit_pending_issue314_state_health_repair_summary(bot)
562+
537563
if touched_items:
538564
if not lock_acquired:
539565
raise RuntimeError(
@@ -542,7 +568,7 @@ def execute_run(bot: AppExecutionRuntime, context: EventContext) -> ExecutionRes
542568
)
543569
_revalidate_epoch(bot, loaded_epoch, "status-label projection")
544570
try:
545-
status_labels_changed = bot.adapters.workflow.sync_status_labels_for_items(state, touched_items)
571+
status_labels_changed = bot.adapters.workflow.sync_status_labels_for_items(state, touched_items)
546572
except RuntimeError as exc:
547573
projection_failure = exc
548574
_log(

scripts/reviewer_bot_lib/config.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,11 +122,13 @@
122122
STATUS_AWAITING_REVIEWER_RESPONSE_LABEL = "status: awaiting reviewer response"
123123
STATUS_AWAITING_CONTRIBUTOR_RESPONSE_LABEL = "status: awaiting contributor response"
124124
STATUS_AWAITING_WRITE_APPROVAL_LABEL = "status: awaiting write approval"
125+
STATUS_REVIEWER_REASSIGNMENT_NEEDED_LABEL = "status: reviewer reassignment needed"
125126
STATUS_AWAITING_REVIEW_COMPLETION_LABEL = STATUS_AWAITING_REVIEWER_RESPONSE_LABEL
126127
STATUS_LABELS = {
127128
STATUS_AWAITING_REVIEWER_RESPONSE_LABEL,
128129
STATUS_AWAITING_CONTRIBUTOR_RESPONSE_LABEL,
129130
STATUS_AWAITING_WRITE_APPROVAL_LABEL,
131+
STATUS_REVIEWER_REASSIGNMENT_NEEDED_LABEL,
130132
}
131133
STATUS_LABEL_CONFIG = {
132134
STATUS_AWAITING_REVIEWER_RESPONSE_LABEL: {
@@ -141,6 +143,10 @@
141143
"color": "1d76db",
142144
"description": "Assigned review is complete but no visible write+ approval is present",
143145
},
146+
STATUS_REVIEWER_REASSIGNMENT_NEEDED_LABEL: {
147+
"color": "d93f0b",
148+
"description": "Reviewer-bot exhausted the active reviewer cycle and reassignment may be needed",
149+
},
144150
MANDATORY_TRIAGE_APPROVER_LABEL: {
145151
"color": "d73a4a",
146152
"description": "Indicates triage+ approval is required before merge queue",

0 commit comments

Comments
 (0)