Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,10 @@ public enum AuditAction {
DEPLOYMENT_APPROVED,
DEPLOYMENT_REJECTED,
DEPLOYMENT_TIMED_OUT,
/** #985: a deployment review idle past its plan's escalation_after_hours was escalated. */
DEPLOYMENT_REVIEW_ESCALATED,
/** #985: undecided deployment reviewers were re-nudged on the plan's nudge_interval_hours. */
DEPLOYMENT_REVIEW_NUDGED,
DEPLOYMENT_CANCELLED,
/** #695: a deployment break-glass retro-review acknowledged (was generic BREAK_GLASS_REVIEWED). */
DEPLOYMENT_BREAK_GLASS_REVIEWED,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,11 @@ public interface DeploymentNotificationLookupService {

/** The reviewers who granted the deployment — an APPROVED review decision on the request. */
List<UUID> findApproverUserIds(UUID deploymentRequestId);

/**
* Every reviewer who has recorded a decision of any kind on the request (#985) — the set a
* review nudge or escalation subtracts from the eligible reviewers, so only the people the
* deployment is still waiting on are reminded.
*/
List<UUID> findDecidedReviewerUserIds(UUID deploymentRequestId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.bablsoft.accessflow.deploygov.events;

import java.util.UUID;

/**
* Published by {@code DeploymentRequestStateService} (#985) when a {@code PENDING_REVIEW}
* deployment request is escalated past its resolved review plan's
* {@code escalation_after_hours}. Notify-only: it never touches eligibility or the
* decision path.
*/
public record DeploymentReviewEscalatedEvent(UUID deploymentRequestId) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.bablsoft.accessflow.deploygov.events;

import java.util.UUID;

/**
* Published by {@code DeploymentRequestStateService} (#985) when a {@code PENDING_REVIEW}
* deployment request is re-nudged on its resolved review plan's
* {@code nudge_interval_hours}. Notify-only: it never touches eligibility or the
* decision path.
*/
public record DeploymentReviewNudgedEvent(UUID deploymentRequestId) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,17 @@ public List<UUID> findApproverUserIds(UUID deploymentRequestId) {
.toList();
}

@Override
@Transactional(readOnly = true)
public List<UUID> findDecidedReviewerUserIds(UUID deploymentRequestId) {
return decisionRepository
.findByDeploymentRequestIdOrderByStageAscDecidedAtAsc(deploymentRequestId)
.stream()
.map(d -> d.getReviewerId())
.distinct()
.toList();
}

/** The environment's plan override wins over the pipeline's — same rule as review/routing. */
private ReviewPlanSnapshot resolvePlan(DeploymentRequestEntity request) {
var pipeline = pipelineRepository.findById(request.getPipelineId()).orElse(null);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import com.bablsoft.accessflow.deploygov.api.DeploymentRequestNotFoundException;
import com.bablsoft.accessflow.deploygov.api.IllegalDeploymentRequestStateException;
import com.bablsoft.accessflow.deploygov.events.DeploymentDecidedEvent;
import com.bablsoft.accessflow.deploygov.events.DeploymentReviewEscalatedEvent;
import com.bablsoft.accessflow.deploygov.events.DeploymentReviewNudgedEvent;
import com.bablsoft.accessflow.deploygov.events.DeploymentStatusChangedEvent;
import com.bablsoft.accessflow.deploygov.internal.persistence.entity.DeploymentRequestEntity;
import com.bablsoft.accessflow.deploygov.internal.persistence.repo.DeploymentRequestRepository;
Expand All @@ -15,6 +17,7 @@
import org.springframework.transaction.annotation.Transactional;

import java.time.Clock;
import java.time.Instant;
import java.util.EnumMap;
import java.util.EnumSet;
import java.util.Map;
Expand Down Expand Up @@ -131,4 +134,45 @@ public boolean markTimedOut(UUID id) {
"review_timeout"));
return true;
}

/**
* Stamps {@code escalated_at} (#985), called per row by {@code DeploymentReviewEscalationJob}.
* Re-checks status and the prior stamp under a row lock, so a decision racing the scan produces
* no stray notification and a sibling replica cannot double-fire. Notify-only: eligibility, the
* self-approval rule and the decision path never read the stamp.
*/
@Transactional
public boolean markEscalated(UUID id, Instant at) {
var entity = repository.findByIdForUpdate(id).orElse(null);
if (entity == null || entity.getStatus() != QueryStatus.PENDING_REVIEW
|| entity.getEscalatedAt() != null) {
return false;
}
entity.setEscalatedAt(at);
repository.save(entity);
recordReminder(AuditAction.DEPLOYMENT_REVIEW_ESCALATED, entity, "escalation");
eventPublisher.publishEvent(new DeploymentReviewEscalatedEvent(id));
return true;
}

/** Stamps {@code last_nudged_at} (#985); a no-op once the request has left review. */
@Transactional
public boolean markNudged(UUID id, Instant at) {
var entity = repository.findByIdForUpdate(id).orElse(null);
if (entity == null || entity.getStatus() != QueryStatus.PENDING_REVIEW) {
return false;
}
entity.setLastNudgedAt(at);
repository.save(entity);
recordReminder(AuditAction.DEPLOYMENT_REVIEW_NUDGED, entity, "nudge");
eventPublisher.publishEvent(new DeploymentReviewNudgedEvent(id));
return true;
}

private void recordReminder(AuditAction action, DeploymentRequestEntity entity, String trigger) {
auditWriter.record(action, AuditResourceType.DEPLOYMENT_REQUEST, entity.getId(),
entity.getOrganizationId(), null,
Map.of("trigger", trigger, "pipeline_id", entity.getPipelineId().toString()),
null);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.bablsoft.accessflow.deploygov.internal.config;

import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;

@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(DeploygovProperties.class)
class DeploygovConfiguration {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.bablsoft.accessflow.deploygov.internal.config;

import org.springframework.boot.context.properties.ConfigurationProperties;

import java.time.Duration;

/**
* Tunables for the deployment-governance module.
*
* <ul>
* <li>{@code timeoutCheck} — cadence of {@code DeploymentTimeoutJob} (#692).</li>
* <li>{@code releaseCheck} — cadence of {@code ScheduledDeploymentReleaseJob} (#693).</li>
* <li>{@code escalationPollInterval} — cadence of {@code DeploymentReviewEscalationJob} (#985).</li>
* </ul>
*
* <p>The jobs read these keys directly through their {@code @Scheduled} placeholders; the record is
* the typed home for the same keys, not a second source. Keep exactly one constructor — a second
* one on a {@code @ConfigurationProperties} record silently unbinds every property.
*/
@ConfigurationProperties("accessflow.deploygov")
public record DeploygovProperties(Duration timeoutCheck, Duration releaseCheck,
Duration escalationPollInterval) {

public DeploygovProperties {
if (timeoutCheck == null) {
timeoutCheck = Duration.ofMinutes(5);
}
if (releaseCheck == null) {
releaseCheck = Duration.ofMinutes(1);
}
if (escalationPollInterval == null) {
escalationPollInterval = Duration.ofMinutes(5);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,13 @@ public class DeploymentRequestEntity {
@Column(name = "release_notified_at")
private Instant releaseNotifiedAt;

// #985: idle-reviewer cursors stamped by DeploymentReviewEscalationJob; notify-only.
@Column(name = "escalated_at")
private Instant escalatedAt;

@Column(name = "last_nudged_at")
private Instant lastNudgedAt;

@Column(name = "submitted_ip", length = 45)
private String submittedIp;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,36 @@ List<DeploymentVersionExecution> findSuccessfulVersionExecutions(
""", nativeQuery = true)
List<UUID> findStalePendingReviewIds(@Param("now") Instant now);

/**
* Escalation scan (#985): {@code PENDING_REVIEW} requests past their resolved plan's
* {@code escalation_after_hours}, not yet escalated. Same plan resolution and clock
* ({@code created_at}) as {@link #findStalePendingReviewIds}, so escalation precedes the timeout.
*/
@Query(value = """
SELECT r.id FROM deployment_requests r
JOIN deployment_environments e ON e.id = r.environment_id
JOIN deployment_pipelines p ON p.id = r.pipeline_id
JOIN review_plans rp ON rp.id = COALESCE(e.review_plan_id, p.review_plan_id)
WHERE r.status = 'PENDING_REVIEW'::query_status
AND r.escalated_at IS NULL
AND rp.escalation_after_hours IS NOT NULL
AND r.created_at + (rp.escalation_after_hours || ' hours')::interval < :now
""", nativeQuery = true)
List<UUID> findEscalationDueIds(@Param("now") Instant now);

/** Nudge scan (#985): requests due a reminder on their resolved plan's nudge cadence. */
@Query(value = """
SELECT r.id FROM deployment_requests r
JOIN deployment_environments e ON e.id = r.environment_id
JOIN deployment_pipelines p ON p.id = r.pipeline_id
JOIN review_plans rp ON rp.id = COALESCE(e.review_plan_id, p.review_plan_id)
WHERE r.status = 'PENDING_REVIEW'::query_status
AND rp.nudge_interval_hours IS NOT NULL
AND COALESCE(r.last_nudged_at, r.created_at)
+ (rp.nudge_interval_hours || ' hours')::interval < :now
""", nativeQuery = true)
List<UUID> findNudgeDueIds(@Param("now") Instant now);

/**
* Release-announcement scan (#693): {@code APPROVED} requests not yet announced whose
* scheduled moment (if any) has passed. Backed by the partial index
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package com.bablsoft.accessflow.deploygov.internal.scheduled;

import com.bablsoft.accessflow.deploygov.internal.DeploymentRequestStateService;
import com.bablsoft.accessflow.deploygov.internal.persistence.repo.DeploymentRequestRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import net.javacrumbs.shedlock.spring.annotation.SchedulerLock;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import java.time.Clock;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
import java.util.function.BiPredicate;

/**
* The deployment twin of {@code ApiReviewEscalationJob} (#985): escalates deployment reviews that
* have sat in {@code PENDING_REVIEW} past their resolved review plan's
* {@code escalation_after_hours} (environment override, else the pipeline's), and re-nudges
* undecided reviewers on {@code nudge_interval_hours}. Both clocks run from {@code created_at}, like
* {@code DeploymentTimeoutJob}, so an escalation always precedes the timeout it warns about.
*
* <p>Notify-only: neither path touches eligibility, the self-approval rule or the decision code.
*/
@Component
@RequiredArgsConstructor
@Slf4j
public class DeploymentReviewEscalationJob {

private final DeploymentRequestRepository requestRepository;
private final DeploymentRequestStateService stateService;
private final Clock clock;

@Scheduled(fixedDelayString = "${accessflow.deploygov.escalation-poll-interval:PT5M}")
@SchedulerLock(name = "deploymentReviewEscalationJob", lockAtMostFor = "PT10M",
lockAtLeastFor = "PT30S")
public void run() {
var now = clock.instant();
var escalated = process("escalate", requestRepository.findEscalationDueIds(now),
stateService::markEscalated, now);
var nudged = process("nudge", requestRepository.findNudgeDueIds(now),
stateService::markNudged, now);
if (escalated > 0 || nudged > 0) {
log.info("Deployment review escalation pass: escalated {}, nudged {}", escalated,
nudged);
}
}

private int process(String what, List<UUID> ids, BiPredicate<UUID, Instant> mark, Instant now) {
var applied = 0;
for (var id : ids) {
try {
if (mark.test(id, now)) {
applied++;
}
} catch (RuntimeException ex) {
// One bad row must not abort the batch; the next tick retries it.
log.error("Failed to {} deployment request {}", what, id, ex);
}
}
return applied;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
import com.bablsoft.accessflow.deploygov.events.DeploymentBreakGlassExecutedEvent;
import com.bablsoft.accessflow.deploygov.events.DeploymentDecidedEvent;
import com.bablsoft.accessflow.deploygov.events.DeploymentOutcomeReportedEvent;
import com.bablsoft.accessflow.deploygov.events.DeploymentReviewEscalatedEvent;
import com.bablsoft.accessflow.deploygov.events.DeploymentReviewNudgedEvent;
import com.bablsoft.accessflow.deploygov.events.DeploymentStatusChangedEvent;
import com.bablsoft.accessflow.notifications.api.NotificationEventType;
import lombok.RequiredArgsConstructor;
Expand All @@ -18,9 +20,10 @@
* deployment that a routing policy auto-decides never pings them. A decided deployment alerts the
* submitter ({@code TIMED_OUT} folds into {@code DEPLOYMENT_REJECTED} with its
* {@code review_timeout} reason); a {@code FAILED} or {@code ROLLED_BACK} outcome alerts the
* reviewers who approved it; a break-glass release alerts every org admin. Break-glass publishes no
* {@code DeploymentDecidedEvent}, so the submitter never self-notifies on that path. Delivery is
* best-effort and never blocks the deployment flow.
* reviewers who approved it; a break-glass release alerts every org admin; an idle review is
* escalated / nudged via the shared {@code REVIEW_ESCALATED} / {@code REVIEW_NUDGE} types (#985).
* Break-glass publishes no {@code DeploymentDecidedEvent}, so the submitter never self-notifies on
* that path. Delivery is best-effort and never blocks the deployment flow.
*/
@Component
@RequiredArgsConstructor
Expand Down Expand Up @@ -59,6 +62,20 @@ void onBreakGlassExecuted(DeploymentBreakGlassExecutedEvent event) {
event.deploymentRequestId(), null, null);
}

// #985: reuse the #622 event types rather than adding deployment-specific ones; the context
// builder resolves recipients (undecided reviewers, plus admins on escalation).
@ApplicationModuleListener
void onReviewEscalated(DeploymentReviewEscalatedEvent event) {
dispatcher.dispatchDeployment(NotificationEventType.REVIEW_ESCALATED,
event.deploymentRequestId(), null, null);
}

@ApplicationModuleListener
void onReviewNudged(DeploymentReviewNudgedEvent event) {
dispatcher.dispatchDeployment(NotificationEventType.REVIEW_NUDGE,
event.deploymentRequestId(), null, null);
}

private static NotificationEventType mapStatus(QueryStatus status) {
return switch (status) {
case APPROVED -> NotificationEventType.DEPLOYMENT_APPROVED;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;

Expand Down Expand Up @@ -510,7 +511,9 @@ private List<RecipientView> apiRecipients(NotificationEventType eventType, ApiRe
* {@code justification}, and the run provenance / change-ticket inputs in {@code deployment}
* (#991). {@code reviewUrl} deep-links the deployment detail page.
* SUBMITTED fans out to the eligible reviewers, OUTCOME_FAILED to the reviewers who approved,
* BREAK_GLASS_EXECUTED to every active org admin, and APPROVED/REJECTED to the submitter.
* BREAK_GLASS_EXECUTED to every active org admin, REVIEW_NUDGE to the eligible reviewers who
* have not decided, REVIEW_ESCALATED to those plus every active org admin (#985, never the
* submitter), and APPROVED/REJECTED to the submitter.
*/
Optional<NotificationContext> buildDeployment(NotificationEventType eventType,
UUID deploymentRequestId,
Expand Down Expand Up @@ -569,6 +572,17 @@ private List<RecipientView> deploymentRecipients(NotificationEventType eventType
// The reviewers who granted the deployment learn it failed or was rolled back.
case DEPLOYMENT_OUTCOME_FAILED -> toActiveRecipients(
deploymentNotificationLookupService.findApproverUserIds(view.id()));
// #985: a nudge reminds the eligible reviewers who have not decided yet; an escalation
// adds every active org admin, because the point is that those reviewers did not act.
// Notify-only — neither widens who may decide, and the submitter is never included.
case REVIEW_NUDGE -> toActiveRecipients(undecidedDeploymentReviewers(view));
case REVIEW_ESCALATED -> {
var ids = new LinkedHashSet<>(undecidedDeploymentReviewers(view));
userQueryService.findByOrganizationAndRole(view.organizationId(), UserRoleType.ADMIN)
.forEach(admin -> ids.add(admin.id()));
ids.remove(view.submittedByUserId());
yield toActiveRecipients(List.copyOf(ids));
}
case DEPLOYMENT_BREAK_GLASS_EXECUTED -> userQueryService
.findByOrganizationAndRole(view.organizationId(), UserRoleType.ADMIN)
.stream()
Expand All @@ -582,6 +596,15 @@ private List<RecipientView> deploymentRecipients(NotificationEventType eventType
};
}

private List<UUID> undecidedDeploymentReviewers(DeploymentNotificationView view) {
var decided = Set.copyOf(
deploymentNotificationLookupService.findDecidedReviewerUserIds(view.id()));
return deploymentNotificationLookupService.findEligibleReviewerUserIds(view.id()).stream()
.filter(id -> !decided.contains(id))
.filter(id -> !id.equals(view.submittedByUserId()))
.toList();
}

/**
* Builds the context for a schema-change promotion notification (#882). Not query-backed: the
* pipeline rides in {@code datasourceId}/{@code datasourceName}, the environment in
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,9 @@ private List<NotificationChannelEntity> resolveChannels(NotificationEventType ev
|| eventType == NotificationEventType.DEPLOYMENT_REJECTED
|| eventType == NotificationEventType.DEPLOYMENT_OUTCOME_FAILED
|| eventType == NotificationEventType.DEPLOYMENT_BREAK_GLASS_EXECUTED
// #985: the shared REVIEW_ESCALATED / REVIEW_NUDGE on a deployment carry the
// pipeline in datasourceId, so they fan out org-wide like every deployment event.
|| ctx.deploymentRequestId() != null
// #882: schema-change events carry the pipeline, not a datasource, in datasourceId
// — a plan-channel lookup would find nothing, so they fan out org-wide too.
|| eventType == NotificationEventType.SCHEMA_CHANGE_PROMOTION_SUBMITTED
Expand Down
Loading
Loading