Skip to content

feat(AF-622): idle-reviewer escalation and nudge reminders - #724

Merged
babltiga merged 16 commits into
mainfrom
feature/AF-622-review-escalation
Aug 17, 2026
Merged

feat(AF-622): idle-reviewer escalation and nudge reminders#724
babltiga merged 16 commits into
mainfrom
feature/AF-622-review-escalation

Conversation

@babltiga

Copy link
Copy Markdown
Contributor

Closes #622.

Second and final PR for AF-622. PR 1 (#723) shipped out-of-office delegation; this one adds
idle escalation and nudge reminders, so a review that nobody picks up gets escalated and
re-announced before QueryTimeoutJob auto-rejects it — instead of the submitter discovering the
timeout after the fact.

Escalation is notify-only. Nothing here changes who may approve: no eligibility path reads
escalated_at, escalation_after_hours, or nudge_interval_hours. An escalated request is
decidable by exactly the same people it was before.

What's in it

Per-plan configuration. review_plans gains escalation_after_hours and
nudge_interval_hours (V144), both nullable with null meaning off, so every existing plan is
unaffected until an admin opts in. Both are editable from the review-plan modal and via the API.

On PUT /review-plans/{id} an explicit null clears the setting. These are the only two
review-plan fields where null is meaningful rather than "leave unchanged", so
UpdateReviewPlanCommand carries explicit clearEscalationAfterHours / clearNudgeIntervalHours
flags — a partial-update record cannot tell absent from null on its own, and without them
escalation would have been a one-way switch.

Three jobs, one per request type. ReviewEscalationJob (queries), ApiReviewEscalationJob
(API requests) and GroupReviewEscalationJob (bundles), each on its own
ACCESSFLOW_*_ESCALATION_POLL_INTERVAL (default PT5M), each @SchedulerLocked, each taking an
injected Clock and swallowing per-row RuntimeExceptions per
.claude/patterns/scheduled-job.md.

Escalation fires once per request: escalated_at is stamped in the same transaction as the
event publish, and the stamp is re-checked under the row lock, so a restart, a retry, a second
replica, or a decision racing the scan cannot produce a duplicate. Nudges repeat on the cadence,
with last_nudged_at as the cursor — measured from submission until the first reminder, from the
last reminder after that.

A grouped request has no plan of its own, so its window is the minimum non-null value across
its members' plans — the strictest member decides, matching the weakest-link union
GroupReviewPlanResolver already uses. The EXISTS guard beside the COALESCE(MIN(...), 0) is
load-bearing: without it a bundle whose members all have escalation switched off would read as
instantly due.

Notifications. Two new NotificationEventType values with the full fan-out from
.claude/patterns/notification-fanout.md — all ten Java switch sites (three of which have a
default and would have compiled silently wrong), two Thymeleaf templates, subject and body keys
across seven messages*.properties, and the frontend union, bell case and seven locale JSONs.

Recipients are the stage the request is blocked on, resolved through the new
core.api.ReviewStages.current — the same definition DefaultReviewService uses to decide who
may act. Sharing it is the point: a second copy that drifted would notify one set of reviewers
while authorizing another. REVIEW_ESCALATED adds every active org admin on top, because the whole
point is that the assigned reviewers did not act; REVIEW_NUDGE goes to those reviewers alone.

PagerDuty pages on escalation through a new REVIEW_STALLED trigger, and has no trigger at all
for REVIEW_NUDGE: a reminder is not an incident.

An escalation window must be shorter than the approval timeout. A longer one can never fire —
QueryTimeoutJob auto-rejects first — so it is now rejected rather than saved as a setting that
silently does nothing.

UI. An escalation banner on the query detail page, and the two new numeric fields in the
review-plan modal.

Grouped requests are stamped but silent, and have no nudge at all

GroupReviewEscalationJob writes request_groups.escalated_at and does nothing else — no
notification and no queue badge. That is not an oversight left half-done: the requestgroups
module has no notification path whatsoever (no listener, no context builder), so grouped
requests have never emitted a submitted or approved message either. Publishing an event nothing
consumes would have been worse than not publishing one. The column records which bundles went idle
so the history is already there when a grouped-request notification path arrives.

For the same reason there is no group nudge half: a reminder needs somebody to remind, so a
group nudge could only advance a cursor nobody reads — rewriting every pending bundle on every
interval for no observable effect. request_groups therefore has escalated_at and no
last_nudged_at. Called out in docs/03-data-model.md, docs/09-deployment.md and on the website.

Two shared event types, and what they broke

REVIEW_ESCALATED / REVIEW_NUDGE are the first events raised for both queries and API
requests, which quietly invalidated three assumptions that held while every event belonged to one
kind:

  • the notification bell routed on the event name, sending a stalled API request to the SQL review
    queue where it does not appear — it now branches on api_request_id;
  • email keyed off the event type, so an API escalation rendered the query template with an empty
    SQL-preview block — the gate now takes the context, per the AF-500 convention that API-request
    events deliver in-app and over chat only;
  • the PagerDuty dedup key was org + subject, so a CRITICAL-risk query that then stalled folded into
    the incident its risk score had already opened and paged nobody — the event type is now part of
    the key.

Also fixed while here: both stamping services documented a row lock neither took. That mattered
beyond the comment — once-only rested on @Version, and the loser of a race could be the human
reviewer, surfacing as a raw 500 on their approve click. Both now take findByIdForUpdate.

Testing

Unit tests for all three jobs and the state-service stamping, plus repository integration tests
against real PostgreSQL
for the six new native queries. Those are not optional here: the queries
are hand-written SQL with enum casts, optional joins and interval arithmetic, the unit tests mock
the repository, and the jobs swallow per-row exceptions — so a mistyped column would have surfaced
only as a job that quietly never escalates anything. Review pass on this branch caught exactly that
(i.request_group_id for a column actually named group_id); these tests are the gate that closes
it.

Unit tests were added for everything two independent review passes found untested: the clear
flags, ApiRequestStateService (which had none at all — the job tests mock it, so its six branches
never executed), the group state service, current-stage recipient resolution, the bell routing, and
the four notification factories whose switches carry a default and would ship a bare enum name
into a real ServiceNow ticket.

  • mvn verify -Pcoverage — green, 6341 tests
  • npm run lint, npm run build, npm run test:coverage — green (92.3% statements, 84.4% branches)

One caveat worth recording: an earlier run of the full suite failed once in
AttestationLifecycleIntegrationTest, which this branch does not touch. Its @AfterEach
blanket-wipes orgs, users, permissions and user_notifications in the container the whole suite
shares, and the log shows an async notification thread from an earlier test hitting a FK violation
on a user that cleanup had just deleted. It passes alone and passes run directly alongside the new
escalation integration tests, and the re-run of the full suite was clean — a latent ordering race
that adding test classes exposed, not a defect here. Filed separately rather than folding an
unrelated test-isolation fix into this PR.

Note for the reviewer

Commit 1352505 carries an unrelated one-line fix to backend/docker-compose-dev.yml (the PG18
volume-mount path) that was in the working tree and got swept in. It is correct and independent of
this feature; say the word and I'll pull it out.

review_plans gains escalation_after_hours and nudge_interval_hours (V144),
both nullable with null meaning off, so every existing plan keeps exactly
today's behaviour on upgrade. Plain INT hours to match
approval_timeout_hours — the codebase confines Duration to
@ConfigurationProperties and never puts it on an entity.

Escalation state (escalated_at, last_nudged_at) lands on the three request
tables rather than the plan: it is per-request progress, and stamping it is
what will make the scan job idempotent across replicas and restarts.
Partial indexes cover only rows still awaiting review.

Neither column widens who may approve. Escalation is notify-only by
construction — the eligibility path does not read them — because idleness
must never become a way around the configured approver set.

The DTO ripple uses convenience constructors throughout, so every existing
caller compiles unchanged. Both bootstrap fingerprint maps gained the new
keys, with a test proving a plan whose escalation delay alone changed is
actually reconciled — miss that and an operator's config change would
silently never apply.
Two new NotificationEventType values with the full fan-out from
.claude/patterns/notification-fanout.md: all ten Java switch sites, two
Thymeleaf templates, subject and body keys across seven messages files,
and the frontend union, bell case, route and seven locale JSONs.

Three of those sites carry a default branch and would have compiled
silently wrong: PagerDutyPayloadFactory, EmailNotificationStrategy's
subject-args switch, and TicketDescriptionBuilder. The subject-args case is
spelled out even though it matches the default, so the choice reads as
deliberate rather than as an accident of the switch having one.

The bell case matters more than it looks: the backend records an in-app row
for every event except TEST, so without it every recipient would see a
contentless 'New notification' — the exact miss #625 shipped with.

Recipients differ by intent. An escalation goes to the plan's reviewers
PLUS every org admin, because the whole point is that the original
reviewers did not act; a nudge goes only to the reviewers already on the
hook. Both keep the plan's channels rather than fanning out org-wide,
matching the other review events.

PagerDuty pages on escalation but not on a nudge — a reminder is not an
incident.
ReviewEscalationJob closes the gap the issue names: today an approval
chain stalls in silence until QueryTimeoutJob auto-rejects the request at
approval_timeout_hours, and the submitter finds out by being rejected.
This fires the warning shots first — escalate once past
escalation_after_hours, re-nudge undecided reviewers on
nudge_interval_hours.

Notify-only by construction. Neither path touches the decision or
eligibility code, and a test asserts the job never calls markTimedOut,
recordApprovalAndAdvance or recordRejection — idleness must not become a
way around the configured approver set.

Both marks are stamped under the row lock in the state service, which
re-checks status and prior stamp there rather than trusting the scan. That
is what makes the job safe on every replica and across restarts: the stamp
decides who wins, so a decision racing the scan yields no stray
notification and a sibling replica cannot double-fire. Events publish
inside that same transaction, so the AFTER_COMMIT listener only ever sees
an escalation that actually took.

Injects Clock rather than calling Instant.now(), per the scheduled-job
pattern — QueryTimeoutJob and ScheduledQueryRunJob both deviate and are
correspondingly untestable on 'is it due yet'. workflow/internal/scheduled
had no tests at all; this adds the first.
The API-request twin of ReviewEscalationJob, scanning api_requests against
their connector's review plan. It lives in apigov rather than workflow
because api_requests is owned there — the same reason ApiRequestTimeoutJob
sits beside QueryTimeoutJob instead of inside it.

Recipients follow intent, not symmetry: a nudge reminds exactly the people
API_REQUEST_SUBMITTED alerted, while an escalation adds admins, because the
point of escalating is that those reviewers did not act.

Same idempotency shape as the query job — status and prior stamp re-checked
before the write, events published in that transaction — so a decision
racing the scan yields no stray notification and replicas cannot
double-fire.
A bundle has no review plan of its own, so its window is the MINIMUM
non-null escalation_after_hours across its members' plans — the strictest
member decides, matching the weakest-link union GroupReviewPlanResolver
already applies to approvers. A member whose plan has escalation off
contributes nothing to that minimum rather than switching it off for the
whole bundle, which is why the query carries an EXISTS guard alongside the
MIN: COALESCE(MIN(...), 0) alone would make every bundle instantly due.

Stamps only, and publishes no event. The requestgroups module has no
notification path whatsoever — no listener, no context builder — so
grouped requests have never emitted a submitted or approved message
either. Publishing events nothing consumes would be dead code, so
escalation raises the flag the review queue reads and channel fan-out
arrives when grouped-request notifications do. The javadoc says so rather
than implying a delivery that does not happen.
Adds the escalation banner on the query detail page, and the two optional
plan settings that drive it.

Both settings default to empty on create and are never prefilled from a
template: a plan that starts nagging reviewers because someone picked a
template would be a surprise, and null is the documented 'off' value. A
cleared field submits null rather than undefined, so clearing one on an
existing plan actually turns it off instead of leaving the old value.

The banner shows only while the request is still awaiting a decision, and
its copy says plainly that escalating does not change who may decide —
tested, because a banner that reads like an approval would be worse than
none.

admin-review-plans.spec.ts reaches the modal's numeric inputs positionally
via .ant-input-number-input, and its index map is now two entries longer.
The assertion itself uses .last() so it still resolves the stage row, but
the map was documentation someone would trust — corrected, pinned with a
count assertion, and the spec now fills and round-trips the new fields.
Covers the two per-plan knobs and why null means off, the three jobs and
their registry rows, the notification events with their differing recipient
sets, and the env vars.

Two things are stated plainly rather than left for a reader to discover:
escalation is notify-only and no eligibility path reads either column, and
grouped requests stamp but do not notify because the requestgroups module
has no notification path at all.

The website gains an 'Escalation & reminders' section with its
cfg-review-escalation anchor registered in both frontend/src/config/docs.ts
and website/app.js so the in-app 'View docs' buttons resolve.
…ructor

QueryDetailResponseTest builds a QueryDetailView through the canonical
constructor, so the two fields added for escalation shifted its argument
list.
Four defects found reviewing this branch, each of which would have shipped
silently.

QueryRequestEntity had the new escalated_at field inserted between @Version
and updatedAt, so Hibernate took escalated_at as the version column. That
disables escalation outright (the scan's escalated_at IS NULL is never true
once Hibernate stamps it), shows the banner on every request, and — worse —
takes optimistic locking off query_requests entirely.

The grouped-request scans joined request_group_items on request_group_id;
the column is group_id (V106:63). Every tick would have thrown inside the
per-row catch, escalating nothing and logging nothing above DEBUG.

PUT /review-plans/{id} could set the two knobs but never clear them: absent
and null are indistinguishable in a partial-update record, and null is the
only way to turn escalation back off. UpdateReviewPlanCommand now carries
explicit clear flags — the reconciler sets them when the bootstrap spec omits
the field (declarative config), the controller when the caller sends null.

ReviewPlanSpec had picked up a backward-compatible convenience constructor.
On a @ConfigurationProperties record that breaks value-object binding
completely: Spring resolves one constructor by arity, so every bootstrap
review plan stopped binding, and the exception named pre-existing fields like
name rather than anything new. Removed, with the properties test now
asserting the escalation fields bind so a re-added overload fails loudly.
Three docs and the public website already said REVIEW_ESCALATED pages
PagerDuty. It could not: PagerDutyTrigger had no mapping for it, so the
trigger filter dropped the event before any HTTP call and the case arm in
PagerDutyPayloadFactory was unreachable. The docs described the intent
correctly; the code was what was missing.

REVIEW_STALLED is deliberately its own name rather than reusing ESCALATION,
which is already taken by QUERY_ESCALATED — a routing policy raising the
approval bar at submission. The two mean opposite things: one is a policy
deciding up front that a query needs more scrutiny, the other is nobody
having decided at all.

REVIEW_NUDGE gets no trigger. A reminder is not an incident and must never
wake an on-call responder, so its payload arm is removed rather than left
looking reachable.

Full surface: enum, fromConfig error text, frontend union and checkbox,
seven locales, the notifications doc's trigger list and sample config, the
website copy, and an e2e assertion that the value round-trips through the
backend config codec.
Four places claimed the grouped-request stamp "surfaces the bundle as
escalated in the review queue". Nothing reads request_groups.escalated_at —
there is no queue flag and no notification, because requestgroups has no
notification path at all. The column is write-only for now, and it earns its
place by recording which bundles went idle so the history is already there
when either surface arrives. Said that way instead.

The api-spec escalation prose had been inserted into the middle of the
review-plan endpoint table, orphaning the DELETE row below it. Moved after
the table, and documented that an explicit null clears the setting — the one
place these two fields differ from every other field on the resource.

QueryReviewEscalatedEvent carried an escalationAfterHours nobody read: the
notification layer loads the request and its plan anyway to resolve
recipients. Dropped, matching ApiReviewEscalatedEvent.

The V144 comment claimed created_at means "since it entered review". It does
not — it is submission time. That is the right clock, because
approval_timeout_hours already uses it and escalate-after must be read
against the same baseline as the timeout it precedes; the comment now says
so rather than implying a precision the column does not have.
The six new scans are hand-written native SQL — enum casts, optional joins,
interval arithmetic, a correlated MIN subquery. The unit tests mock the
repository, and the jobs swallow per-row RuntimeExceptions, so a mistyped
column produces a job that quietly never escalates anything. That is exactly
what happened on this branch (request_group_id for a column named group_id),
and nothing in the suite would have caught it.

Seeding is raw JDBC rather than JPA on purpose: going through the entities
would hide any column the entity does not map, which is the whole class of
bug these tests exist to catch. Cleanup is scoped to a marker organization
rather than truncating, because the Testcontainers instance is shared across
the suite and a blanket delete would either destroy another test's rows or
trip a foreign key from a table this helper knows nothing about.

The grouped-request cases carry the most weight — they pin the MIN across
member plans over both datasource and connector members, and assert that a
bundle whose members all have escalation off is never due. Without the
EXISTS guard the COALESCE(..., 0) makes it instantly due, and no Java-side
test can see that.

Also covers markEscalated / markNudged, which had no test at all: escalation
fires once and survives a racing decision, nudges repeat and advance the
cursor.
Escalations and nudges resolved recipients as the plan's LOWEST stage. That
is right for QUERY_SUBMITTED, which fires at submission when lowest and
current are the same stage — but these fire hours or days later, exactly when
a multi-stage request may have moved on.

On a two-stage plan whose stage 1 has approved, the nudge told the stage-1
reviewers who had already decided that the query was still awaiting their
review, and never reached the stage-2 approver actually holding it up.
Neither did the escalation. That is the precise failure the feature exists to
prevent, and it would have looked like a mail-routing complaint rather than a
logic bug.

The fix is to share one definition rather than write a second: currentStage
moves out of DefaultReviewService into core.api.ReviewStages, and both the
module that decides who MAY act and the module that decides who gets TOLD now
call it. A copy that drifted would authorize one set of reviewers while
notifying another.

Everything downstream was copy promising "the next stage" — twelve sites,
including seven locale pairs, the website, the README, and V144's
COMMENT ON COLUMN. All corrected to say what actually happens: the reviewers
the request is waiting on, plus every org admin for an escalation. V144 is
unapplied, so its operator-visible comment can still be fixed in place rather
than needing a follow-up migration to correct.

The locale files also pick up the two PagerDuty trigger labels added in the
next commit — they are the same JSON objects and splitting them would make
both commits unreadable.
REVIEW_ESCALATED and REVIEW_NUDGE are the first event types raised for both
queries and API requests, which quietly broke three assumptions that held
while every event belonged to exactly one kind.

The notification bell routed on the event name alone, so a stalled API
request sent the reviewer to the SQL review queue, where it does not appear.
It now branches on api_request_id, which is the only thing that actually
distinguishes the two.

Email delivery keyed off the event type, so an API escalation rendered the
query template: a bolded "SQL preview:" over an empty block and "Query type:
—", because buildApiRequest has no SQL to supply. API-request events deliver
in-app and over chat by the AF-500 convention, so the gate now takes the
context rather than just the type. (Documented alongside it: because plan
channels resolve by datasource, an API-request context resolves none, so
those escalations reach the bell and the SMTP fallback but not a configured
chat channel. Pre-existing, but this is the first feature that depends on it.)

The PagerDuty dedup key was org + subject, so a CRITICAL-risk query that then
stalled folded into the AI_HIGH_RISK incident its risk score had already
opened — and paged nobody. A stalled critical query is exactly what
REVIEW_STALLED is for. The event type is now part of the key; repeats of the
same event on the same subject still dedupe, which is what dedup is for.

Also adds the ANOMALY and BREAK_GLASS trigger options: the form offered four
of the six values PagerDutyTrigger accepts, so a channel provisioned with
either through bootstrap or the API could not be edited in the UI.

Tests cover the four notification factories whose switches carry a default —
Discord, Teams, Telegram and the ticket-description builder — where a missing
arm compiles and ships a bare enum name into a real ServiceNow ticket.
Both stamping services documented a row lock neither took: markEscalated and
markNudged used a plain findById while the javadoc promised "under a row
lock, so a decision racing the scan produces no stray notification and a
sibling replica cannot double-fire". Only the query path did it properly.

That was worse than an inaccurate comment. With no pessimistic lock, once-only
rested entirely on @Version, and the loser of a race can be the human:
DefaultApiReviewService loads the request unlocked, so a reviewer approving
inside the 5-minute escalation window hits an optimistic-lock failure. There
is no ControllerAdvice branch for it, so that surfaces as a raw 500 on a
reviewer's approve click. Both repositories now expose findByIdForUpdate and
both stamps use it — the loser waits instead of failing.

The grouped-request nudge path is removed rather than fixed. A nudge is a
reminder sent to somebody, and requestgroups has no notification path at all,
so a group nudge could only advance a cursor nobody reads — an UPDATE and a
@Version bump on every pending bundle, every interval, forever, with no
observable effect. request_groups therefore keeps escalated_at, which is
once-only and records which bundles went idle, and loses last_nudged_at
entirely. The two non-sargable nudge indexes go with it: the predicate is
COALESCE(last_nudged_at, created_at) + interval, so leading an index with
last_nudged_at bought nothing and cost a write on every stamp.

ApiRequestStateService had no test at all — its six branches never executed,
since the job tests mock it. It has one now, including an assertion that the
stamp takes the lock rather than a plain read.

Also adds the requestgroups poll-interval entry to application.yml, which was
the only one of the three jobs missing from the file.
escalation_after_hours and approval_timeout_hours were validated
independently (1–8760 each), so a 48-hour escalation window on a plan with
the default 24-hour timeout saved cleanly and then did nothing: QueryTimeoutJob
auto-rejects the request before the escalation scan ever sees it.

Nothing surfaced that. No error, no warning, and no escalation — an admin
would reasonably conclude the feature was broken. Every doc frames escalation
as the warning shot BEFORE the timeout, so the setting is now validated to
match that, on create and on update, against the post-merge values rather than
the request body (a partial update can move either side).

Tests cover the clear-flag branches too, which had none — they are what a
prior review pass caught as "PUT could never disable escalation", and they
would otherwise have shipped back unguarded.
@github-actions

Copy link
Copy Markdown
Contributor

Frontend Test Results

1 507 tests  +8   1 507 ✅ +8   4m 10s ⏱️ -31s
  193 suites ±0       0 💤 ±0 
    1 files   ±0       0 ❌ ±0 

Results for commit d82a5d6. ± Comparison against base commit e628eff.

@github-actions

Copy link
Copy Markdown
Contributor

Coverage Report for Frontend Coverage (frontend)

Status Category Percentage Covered / Total
🟢 Lines 94.18% (🎯 90%) 2235 / 2373
🟢 Statements 92.3% (🎯 90%) 2484 / 2691
🟢 Functions 91.38% (🎯 90%) 679 / 743
🟢 Branches 84.39% (🎯 80%) 1395 / 1653
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
frontend/src/config/docs.ts 100% 100% 100% 100%
frontend/src/pages/admin/reviewPlanTemplateForm.ts 100% 100% 100% 100%
Generated in workflow #956 for commit d82a5d6 by the Vitest Coverage Report Action

@github-actions

Copy link
Copy Markdown
Contributor

Backend Test Results

6 341 tests  +62   6 341 ✅ +62   16m 33s ⏱️ - 11m 32s
  763 suites + 7       0 💤 ± 0 
  763 files   + 7       0 ❌ ± 0 

Results for commit d82a5d6. ± Comparison against base commit e628eff.

@github-actions

Copy link
Copy Markdown
Contributor

Backend Code Coverage

Overall Project 93.19% -0.04% 🍏
Files changed 92.53% 🍏

File Coverage
RequestGroupStateService.java 100% 🍏
QueryReviewEscalatedEvent.java 100% 🍏
QueryReviewNudgedEvent.java 100% 🍏
WorkflowProperties.java 100% 🍏
ApiReviewNudgedEvent.java 100% 🍏
ApiReviewEscalatedEvent.java 100% 🍏
NotificationEventType.java 100% 🍏
ApiReviewEscalationJob.java 100% 🍏
PagerDutyTrigger.java 100% 🍏
ApiRequestEntity.java 100% 🍏
QueryRequestRepository.java 100% 🍏
ReviewEscalationJob.java 100% 🍏
CreateReviewPlanRequest.java 100% 🍏
ReviewPlanResponse.java 100% 🍏
UpdateReviewPlanRequest.java 100% 🍏
ReviewStages.java 100% 🍏
UpdateReviewPlanCommand.java 100% 🍏
CreateReviewPlanCommand.java 100% 🍏
ReviewPlanSnapshot.java 100% 🍏
QueryDetailView.java 100% 🍏
ReviewPlanView.java 100% 🍏
GroupReviewEscalationJob.java 100% 🍏
TicketDescriptionBuilder.java 100% 🍏
RequestGroupEntity.java 100% 🍏
ReviewPlanReconciler.java 97.11% -0.21% 🍏
DefaultReviewService.java 97% 🍏
ReviewPlanSpec.java 96% 🍏
DefaultReviewPlanAdminService.java 94.96% 🍏
ApiRequestStateService.java 94.59% 🍏
DefaultQueryRequestLookupService.java 94.11% -0.4% 🍏
DefaultQueryRequestStateService.java 93.14% 🍏
ReviewPlanController.java 92% -0.8% 🍏
NotificationContextBuilder.java 86.23% -0.87% 🍏
QueryDetailResponse.java 86.07% 🍏
DefaultReviewPlanLookupService.java 85.07% 🍏
MsTeamsPayloadFactory.java 83.8% 🍏
PagerDutyPayloadFactory.java 83.13% 🍏
SlackBlockKitFactory.java 82.77% 🍏
TelegramMessageFactory.java 82.57% 🍏
NotificationDispatcher.java 82.56% 🍏
DiscordPayloadFactory.java 81.78% 🍏
EmailNotificationStrategy.java 76.88% -2.82% 🍏
ApiNotificationListener.java 74.07% -25.93% 🍏
NotificationListener.java 69.26% -7.38% 🍏

@babltiga
babltiga merged commit ec3a0be into main Aug 17, 2026
34 checks passed
@babltiga
babltiga deleted the feature/AF-622-review-escalation branch August 17, 2026 11:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

workflow: reviewer delegation & escalation — out-of-office delegates, idle-reviewer escalation, nudge reminders

1 participant