diff --git a/backend/src/main/java/com/bablsoft/accessflow/deploygov/api/DeploymentRequestView.java b/backend/src/main/java/com/bablsoft/accessflow/deploygov/api/DeploymentRequestView.java index a8c1cd915..74b6cf0f7 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/deploygov/api/DeploymentRequestView.java +++ b/backend/src/main/java/com/bablsoft/accessflow/deploygov/api/DeploymentRequestView.java @@ -47,7 +47,11 @@ public record DeploymentRequestView( List decisions, /** The human an API-key submitter acted for (#874); null for a human submission. */ UUID onBehalfOfUserId, - String onBehalfOfEmail) { + String onBehalfOfEmail, + /** Mirrors the environment's {@code change_ticket_required} flag (#994). */ + boolean changeTicketRequired, + /** The linked change ticket that explains the request's state, or null (#994). */ + DeploymentTicketView changeTicket) { /** Backward-compatible constructor without the #874 on-behalf-of principal. */ public DeploymentRequestView(UUID id, UUID pipelineId, String pipelineName, PipelineProvider provider, @@ -65,7 +69,7 @@ public DeploymentRequestView(UUID id, UUID pipelineId, String pipelineName, Pipe submittedByEmail, version, commitSha, artifactRef, runUrl, externalRunId, metadata, status, submissionReason, justification, aiAnalysisId, aiRiskLevel, aiRiskScore, aiSummary, requiredApprovals, scheduledFor, outcome, outcomeReportedAt, outcomeDetail, - createdAt, decisions, null, null); + createdAt, decisions, null, null, false, null); } public DeploymentRequestView { diff --git a/backend/src/main/java/com/bablsoft/accessflow/deploygov/internal/DefaultDeploymentGateService.java b/backend/src/main/java/com/bablsoft/accessflow/deploygov/internal/DefaultDeploymentGateService.java index c9dfa9142..c3d58d151 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/deploygov/internal/DefaultDeploymentGateService.java +++ b/backend/src/main/java/com/bablsoft/accessflow/deploygov/internal/DefaultDeploymentGateService.java @@ -38,7 +38,6 @@ import java.time.Clock; import java.time.Instant; -import java.util.Comparator; import java.util.LinkedHashMap; import java.util.List; import java.util.Set; @@ -136,7 +135,7 @@ public DeploymentRequestView confirmExecution(UUID requestId, UUID organizationI if (request.getSubmissionReason() == SubmissionReason.EMERGENCY_ACCESS) { metadata.put("change_ticket_bypassed", "break_glass"); } else { - // Releasable implies an APPROVED and no REJECTED ticket, so chooseTicket is non-null. + // Releasable implies an APPROVED and no REJECTED ticket, so the representative ticket is non-null. metadata.put("change_ticket_key", evaluation.changeTicket().externalKey()); } } @@ -210,7 +209,7 @@ GateEvaluation evaluate(DeploymentRequestEntity request, Instant now) { if (ticketRequired) { var tickets = ticketRepository.findAllByDeploymentRequestId(request.getId()); ticketSatisfied = ticketApproved(tickets); - changeTicket = chooseTicket(tickets); + changeTicket = DefaultDeploymentTicketService.representative(tickets); } } return new GateEvaluation( @@ -236,23 +235,6 @@ static boolean ticketApproved(List tickets) { return approved; } - /** - * The ticket that explains the verdict: the newest rejected one (it blocks the gate), else the - * newest approved one, else the newest linked ticket, else null. - */ - private static DeploymentTicketView chooseTicket(List tickets) { - var newestFirst = Comparator.comparing(DeploymentTicketEntity::getCreatedAt, - Comparator.nullsFirst(Comparator.naturalOrder())).reversed(); - return tickets.stream() - .filter(t -> REJECTED.equals(t.getDecision())) - .min(newestFirst) - .or(() -> tickets.stream().filter(t -> APPROVED.equals(t.getDecision())) - .min(newestFirst)) - .or(() -> tickets.stream().min(newestFirst)) - .map(DefaultDeploymentTicketService::toView) - .orElse(null); - } - /** {@code environmentName} is only read on the releasable path, where the row was found. */ record GateEvaluation(boolean releasable, boolean frozen, String freezeReason, boolean ticketRequired, DeploymentTicketView changeTicket, diff --git a/backend/src/main/java/com/bablsoft/accessflow/deploygov/internal/DefaultDeploymentRequestService.java b/backend/src/main/java/com/bablsoft/accessflow/deploygov/internal/DefaultDeploymentRequestService.java index ef17f66a6..ca9a5b44f 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/deploygov/internal/DefaultDeploymentRequestService.java +++ b/backend/src/main/java/com/bablsoft/accessflow/deploygov/internal/DefaultDeploymentRequestService.java @@ -19,6 +19,7 @@ import com.bablsoft.accessflow.deploygov.api.DeploymentRequestSubmissionResult; import com.bablsoft.accessflow.deploygov.api.DeploymentRequestView; import com.bablsoft.accessflow.deploygov.api.DeploymentReviewDecisionView; +import com.bablsoft.accessflow.deploygov.api.DeploymentTicketView; import com.bablsoft.accessflow.deploygov.api.FreezeBehavior; import com.bablsoft.accessflow.deploygov.api.IllegalDeploymentRequestStateException; import com.bablsoft.accessflow.deploygov.api.SubmitDeploymentRequestCommand; @@ -28,10 +29,12 @@ import com.bablsoft.accessflow.deploygov.internal.persistence.entity.DeploymentEnvironmentEntity; import com.bablsoft.accessflow.deploygov.internal.persistence.entity.DeploymentPipelineEntity; import com.bablsoft.accessflow.deploygov.internal.persistence.entity.DeploymentRequestEntity; +import com.bablsoft.accessflow.deploygov.internal.persistence.entity.DeploymentTicketEntity; import com.bablsoft.accessflow.deploygov.internal.persistence.repo.DeploymentEnvironmentRepository; import com.bablsoft.accessflow.deploygov.internal.persistence.repo.DeploymentPipelineRepository; import com.bablsoft.accessflow.deploygov.internal.persistence.repo.DeploymentRequestRepository; import com.bablsoft.accessflow.deploygov.internal.persistence.repo.DeploymentReviewDecisionRepository; +import com.bablsoft.accessflow.deploygov.internal.persistence.repo.DeploymentTicketRepository; import lombok.RequiredArgsConstructor; import org.springframework.context.ApplicationEventPublisher; import org.springframework.dao.DataIntegrityViolationException; @@ -42,12 +45,13 @@ import tools.jackson.core.type.TypeReference; import tools.jackson.databind.ObjectMapper; +import java.time.Clock; import java.util.LinkedHashMap; import java.util.List; -import java.time.Clock; import java.util.Map; import java.util.Set; import java.util.UUID; +import java.util.stream.Collectors; /** * Submission, listing and cancellation of governed deployment requests (#691). Mirrors @@ -71,6 +75,7 @@ public class DefaultDeploymentRequestService implements DeploymentRequestService private final DeploymentPipelineRepository pipelineRepository; private final DeploymentEnvironmentRepository environmentRepository; private final DeploymentReviewDecisionRepository decisionRepository; + private final DeploymentTicketRepository ticketRepository; private final EffectiveDeploymentPermissionResolver permissionResolver; private final FreezeWindowEvaluator freezeWindowEvaluator; private final DeploymentRequestStateService stateService; @@ -332,7 +337,15 @@ private DeploymentRequestEntity require(UUID id, UUID organizationId) { } private PageResponse toPage(Page page) { - var content = page.getContent().stream().map(e -> toView(e, List.of())).toList(); + var ids = page.getContent().stream().map(DeploymentRequestEntity::getId).toList(); + // One ticket query per page, never one per row (#994). + var ticketsByRequest = ids.isEmpty() ? Map.>of() + : ticketRepository.findAllByDeploymentRequestIdIn(ids).stream() + .collect(Collectors.groupingBy(DeploymentTicketEntity::getDeploymentRequestId)); + var content = page.getContent().stream() + .map(e -> toView(e, List.of(), DefaultDeploymentTicketService.representative( + ticketsByRequest.getOrDefault(e.getId(), List.of())))) + .toList(); return new PageResponse<>(content, page.getNumber(), page.getSize() <= 0 ? 1 : page.getSize(), page.getTotalElements(), page.getTotalPages()); } @@ -351,11 +364,15 @@ private DeploymentRequestView toDetailView(DeploymentRequestEntity entity) { .map(d -> new DeploymentReviewDecisionView(d.getId(), d.getReviewerId(), d.getDecision(), d.getComment(), d.getStage(), d.getDecidedAt())) .toList(); - return toView(entity, decisions); + // Unlike the gate, loaded whatever the environment flag: a linked ticket is worth showing. + var changeTicket = DefaultDeploymentTicketService.representative( + ticketRepository.findAllByDeploymentRequestId(entity.getId())); + return toView(entity, decisions, changeTicket); } private DeploymentRequestView toView(DeploymentRequestEntity entity, - List decisions) { + List decisions, + DeploymentTicketView changeTicket) { var pipeline = pipelineRepository.findById(entity.getPipelineId()).orElse(null); var environment = environmentRepository.findById(entity.getEnvironmentId()).orElse(null); var analysis = entity.getAiAnalysisId() == null ? null @@ -393,7 +410,9 @@ private DeploymentRequestView toView(DeploymentRequestEntity entity, entity.getCreatedAt(), decisions, entity.getOnBehalfOfUserId(), - onBehalfOf != null ? onBehalfOf.email() : null); + onBehalfOf != null ? onBehalfOf.email() : null, + environment != null && environment.isChangeTicketRequired(), + changeTicket); } private Map readMetadata(String json) { diff --git a/backend/src/main/java/com/bablsoft/accessflow/deploygov/internal/DefaultDeploymentTicketService.java b/backend/src/main/java/com/bablsoft/accessflow/deploygov/internal/DefaultDeploymentTicketService.java index 568026105..0b96bfee3 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/deploygov/internal/DefaultDeploymentTicketService.java +++ b/backend/src/main/java/com/bablsoft/accessflow/deploygov/internal/DefaultDeploymentTicketService.java @@ -10,6 +10,7 @@ import org.springframework.transaction.annotation.Transactional; import java.time.Clock; +import java.util.Comparator; import java.util.List; import java.util.Objects; import java.util.Optional; @@ -20,6 +21,7 @@ class DefaultDeploymentTicketService implements DeploymentTicketService { private static final String APPROVED = "APPROVED"; + private static final String REJECTED = "REJECTED"; private final DeploymentTicketRepository repository; private final Clock clock; @@ -89,6 +91,24 @@ public List listByDeploymentRequest(UUID deploymentRequest .toList(); } + /** + * The ticket that explains a request's change-ticket state — the newest rejected one (it blocks + * the gate), else the newest approved one, else the newest linked ticket, else null. Shared by + * the gate (#993) and the request list/detail views (#994). + */ + static DeploymentTicketView representative(List tickets) { + var newestFirst = Comparator.comparing(DeploymentTicketEntity::getCreatedAt, + Comparator.nullsFirst(Comparator.naturalOrder())).reversed(); + return tickets.stream() + .filter(t -> REJECTED.equals(t.getDecision())) + .min(newestFirst) + .or(() -> tickets.stream().filter(t -> APPROVED.equals(t.getDecision())) + .min(newestFirst)) + .or(() -> tickets.stream().min(newestFirst)) + .map(DefaultDeploymentTicketService::toView) + .orElse(null); + } + static DeploymentTicketView toView(DeploymentTicketEntity entity) { return new DeploymentTicketView( entity.getId(), diff --git a/backend/src/main/java/com/bablsoft/accessflow/deploygov/internal/persistence/repo/DeploymentTicketRepository.java b/backend/src/main/java/com/bablsoft/accessflow/deploygov/internal/persistence/repo/DeploymentTicketRepository.java index 72f776f93..a42647af4 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/deploygov/internal/persistence/repo/DeploymentTicketRepository.java +++ b/backend/src/main/java/com/bablsoft/accessflow/deploygov/internal/persistence/repo/DeploymentTicketRepository.java @@ -3,6 +3,7 @@ import com.bablsoft.accessflow.deploygov.internal.persistence.entity.DeploymentTicketEntity; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Collection; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -17,4 +18,6 @@ List findAllByDeploymentRequestIdAndOrganizationIdOrderB UUID deploymentRequestId, UUID organizationId); List findAllByDeploymentRequestId(UUID deploymentRequestId); + + List findAllByDeploymentRequestIdIn(Collection deploymentRequestIds); } diff --git a/backend/src/main/java/com/bablsoft/accessflow/deploygov/internal/web/DeploymentChangeTicketResponse.java b/backend/src/main/java/com/bablsoft/accessflow/deploygov/internal/web/DeploymentChangeTicketResponse.java new file mode 100644 index 000000000..0b66686f0 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/deploygov/internal/web/DeploymentChangeTicketResponse.java @@ -0,0 +1,13 @@ +package com.bablsoft.accessflow.deploygov.internal.web; + +import com.bablsoft.accessflow.deploygov.api.DeploymentTicketView; + +/** The change-ticket summary on the gate (#993) and on deployment requests (#994). */ +record DeploymentChangeTicketResponse(String system, String externalKey, String url, String status, + String resolution, String decision) { + + static DeploymentChangeTicketResponse from(DeploymentTicketView view) { + return view == null ? null : new DeploymentChangeTicketResponse(view.ticketSystem(), + view.externalKey(), view.url(), view.status(), view.resolution(), view.decision()); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/deploygov/internal/web/DeploymentGateResponse.java b/backend/src/main/java/com/bablsoft/accessflow/deploygov/internal/web/DeploymentGateResponse.java index 427edf5cc..6f8f43d8d 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/deploygov/internal/web/DeploymentGateResponse.java +++ b/backend/src/main/java/com/bablsoft/accessflow/deploygov/internal/web/DeploymentGateResponse.java @@ -3,7 +3,6 @@ import com.bablsoft.accessflow.core.api.QueryStatus; import com.bablsoft.accessflow.core.api.RiskLevel; import com.bablsoft.accessflow.deploygov.api.DeploymentGateView; -import com.bablsoft.accessflow.deploygov.api.DeploymentTicketView; import java.time.Instant; import java.util.List; @@ -21,25 +20,16 @@ public record DeploymentGateResponse( Instant scheduledFor, RiskLevel aiRiskLevel, boolean changeTicketRequired, - ChangeTicket changeTicket) { + DeploymentChangeTicketResponse changeTicket) { record Approvals(int required, int granted) { } - record ChangeTicket(String system, String externalKey, String url, String status, - String resolution, String decision) { - - static ChangeTicket from(DeploymentTicketView view) { - return view == null ? null : new ChangeTicket(view.ticketSystem(), view.externalKey(), - view.url(), view.status(), view.resolution(), view.decision()); - } - } - static DeploymentGateResponse from(DeploymentGateView view) { return new DeploymentGateResponse(view.requestId(), view.status(), view.releasable(), new Approvals(view.requiredApprovals(), view.grantedApprovals()), view.decisions().stream().map(DeploymentReviewDecisionResponse::from).toList(), view.frozen(), view.freezeReason(), view.scheduledFor(), view.aiRiskLevel(), - view.changeTicketRequired(), ChangeTicket.from(view.changeTicket())); + view.changeTicketRequired(), DeploymentChangeTicketResponse.from(view.changeTicket())); } } diff --git a/backend/src/main/java/com/bablsoft/accessflow/deploygov/internal/web/DeploymentRequestResponse.java b/backend/src/main/java/com/bablsoft/accessflow/deploygov/internal/web/DeploymentRequestResponse.java index 26395e443..5250ab388 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/deploygov/internal/web/DeploymentRequestResponse.java +++ b/backend/src/main/java/com/bablsoft/accessflow/deploygov/internal/web/DeploymentRequestResponse.java @@ -44,7 +44,11 @@ public record DeploymentRequestResponse( boolean canReview, /** The human an API-key submitter acted for (#874); null for a human submission. */ UUID onBehalfOfUserId, - String onBehalfOfEmail) { + String onBehalfOfEmail, + /** Mirrors the environment's flag (#994). */ + boolean changeTicketRequired, + /** The linked change ticket that explains the request's state, or null (#994). */ + DeploymentChangeTicketResponse changeTicket) { /** Every path but the detail read: reviewer eligibility is a detail-read concern, never computed here. */ static DeploymentRequestResponse from(DeploymentRequestView view) { @@ -61,6 +65,8 @@ static DeploymentRequestResponse from(DeploymentRequestView view, boolean canRev view.scheduledFor(), view.outcome(), view.outcomeReportedAt(), view.outcomeDetail(), view.createdAt(), view.decisions().stream().map(DeploymentReviewDecisionResponse::from).toList(), - canReview, view.onBehalfOfUserId(), view.onBehalfOfEmail()); + canReview, view.onBehalfOfUserId(), view.onBehalfOfEmail(), + view.changeTicketRequired(), + DeploymentChangeTicketResponse.from(view.changeTicket())); } } diff --git a/backend/src/test/java/com/bablsoft/accessflow/deploygov/DeploymentTicketPersistenceIntegrationTest.java b/backend/src/test/java/com/bablsoft/accessflow/deploygov/DeploymentTicketPersistenceIntegrationTest.java index 5c04928d4..92aef3a2e 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/deploygov/DeploymentTicketPersistenceIntegrationTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/deploygov/DeploymentTicketPersistenceIntegrationTest.java @@ -25,6 +25,7 @@ import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; import static org.assertj.core.api.Assertions.assertThatThrownBy; /** @@ -111,9 +112,9 @@ void recordsReloadsAndStampsTheDecisionThroughTheService() { null, null).orElseThrow(); assertThat(progressed.status()).isEqualTo("Review"); assertThat(progressed.decision()).isEqualTo("APPROVED"); - // Postgres keeps microseconds; the first view may carry the clock's nanoseconds (Linux). - assertThat(progressed.decidedAt().truncatedTo(ChronoUnit.MICROS)) - .isEqualTo(updated.decidedAt().truncatedTo(ChronoUnit.MICROS)); + // Postgres rounds to microseconds; the first view may carry the clock's nanoseconds (Linux). + assertThat(progressed.decidedAt()) + .isCloseTo(updated.decidedAt(), within(1, ChronoUnit.MICROS)); var listed = ticketService.listByDeploymentRequest(request.getId(), organizationId); assertThat(listed).singleElement().satisfies(view -> { diff --git a/backend/src/test/java/com/bablsoft/accessflow/deploygov/internal/DefaultDeploymentRequestServiceTest.java b/backend/src/test/java/com/bablsoft/accessflow/deploygov/internal/DefaultDeploymentRequestServiceTest.java index 8fb80112e..16c92a56f 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/deploygov/internal/DefaultDeploymentRequestServiceTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/deploygov/internal/DefaultDeploymentRequestServiceTest.java @@ -30,10 +30,12 @@ import com.bablsoft.accessflow.deploygov.internal.persistence.entity.DeploymentEnvironmentEntity; import com.bablsoft.accessflow.deploygov.internal.persistence.entity.DeploymentPipelineEntity; import com.bablsoft.accessflow.deploygov.internal.persistence.entity.DeploymentRequestEntity; +import com.bablsoft.accessflow.deploygov.internal.persistence.entity.DeploymentTicketEntity; import com.bablsoft.accessflow.deploygov.internal.persistence.repo.DeploymentEnvironmentRepository; import com.bablsoft.accessflow.deploygov.internal.persistence.repo.DeploymentPipelineRepository; import com.bablsoft.accessflow.deploygov.internal.persistence.repo.DeploymentRequestRepository; import com.bablsoft.accessflow.deploygov.internal.persistence.repo.DeploymentReviewDecisionRepository; +import com.bablsoft.accessflow.deploygov.internal.persistence.repo.DeploymentTicketRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.context.ApplicationEventPublisher; @@ -75,6 +77,7 @@ class DefaultDeploymentRequestServiceTest { private DeploymentPipelineRepository pipelineRepository; private DeploymentEnvironmentRepository environmentRepository; private DeploymentReviewDecisionRepository decisionRepository; + private DeploymentTicketRepository ticketRepository; private EffectiveDeploymentPermissionResolver permissionResolver; private FreezeWindowEvaluator freezeWindowEvaluator; private DeploymentRequestStateService stateService; @@ -94,6 +97,7 @@ void setUp() { pipelineRepository = mock(DeploymentPipelineRepository.class); environmentRepository = mock(DeploymentEnvironmentRepository.class); decisionRepository = mock(DeploymentReviewDecisionRepository.class); + ticketRepository = mock(DeploymentTicketRepository.class); permissionResolver = mock(EffectiveDeploymentPermissionResolver.class); freezeWindowEvaluator = mock(FreezeWindowEvaluator.class); stateService = mock(DeploymentRequestStateService.class); @@ -102,7 +106,8 @@ void setUp() { userQueryService = mock(UserQueryService.class); eventPublisher = mock(ApplicationEventPublisher.class); service = new DefaultDeploymentRequestService(requestRepository, requestInserter, - pipelineRepository, environmentRepository, decisionRepository, permissionResolver, + pipelineRepository, environmentRepository, decisionRepository, ticketRepository, + permissionResolver, freezeWindowEvaluator, stateService, auditWriter, aiAnalysisLookupService, userQueryService, eventPublisher, JsonMapper.builder().build(), CLOCK); @@ -502,6 +507,82 @@ void listWithoutAnEnvironmentFilterSkipsTheNameLookup() { assertThat(page.content()).isEmpty(); } + @Test + void listLoadsTheChangeTicketsForTheWholePageInOneQuery() { + environment.setChangeTicketRequired(true); + var first = existing(QueryStatus.APPROVED); + var second = existing(QueryStatus.PENDING_REVIEW); + when(requestRepository.findAll(any(Specification.class), any(org.springframework.data.domain.Pageable.class))) + .thenReturn(new PageImpl<>(List.of(first, second))); + when(ticketRepository.findAllByDeploymentRequestIdIn(List.of(first.getId(), second.getId()))) + .thenReturn(List.of( + ticket(first.getId(), "CHG001", null, NOW.minusSeconds(60)), + ticket(first.getId(), "CHG002", "APPROVED", NOW.minusSeconds(120)))); + + var page = service.list(new DeploymentRequestListFilter(ORG, null, null, null, null, null, + null, null), new PageRequest(0, 20, List.of())); + + verify(ticketRepository).findAllByDeploymentRequestIdIn(any()); + verify(ticketRepository, never()).findAllByDeploymentRequestId(any()); + var firstView = page.content().get(0); + assertThat(firstView.changeTicketRequired()).isTrue(); + assertThat(firstView.changeTicket().externalKey()).isEqualTo("CHG002"); + assertThat(firstView.changeTicket().decision()).isEqualTo("APPROVED"); + assertThat(page.content().get(1).changeTicket()).isNull(); + } + + @Test + void listOfAnEmptyPageSkipsTheTicketLookup() { + when(requestRepository.findAll(any(Specification.class), any(org.springframework.data.domain.Pageable.class))) + .thenReturn(new PageImpl<>(List.of())); + + service.list(new DeploymentRequestListFilter(ORG, null, null, null, null, null, null, null), + new PageRequest(0, 20, List.of())); + + verify(ticketRepository, never()).findAllByDeploymentRequestIdIn(any()); + } + + @Test + void getCarriesTheRejectedTicketOverANewerApprovedOne() { + environment.setChangeTicketRequired(true); + var entity = existing(QueryStatus.APPROVED); + when(requestRepository.findByIdAndOrganizationId(entity.getId(), ORG)) + .thenReturn(Optional.of(entity)); + when(ticketRepository.findAllByDeploymentRequestId(entity.getId())).thenReturn(List.of( + ticket(entity.getId(), "CHG010", "REJECTED", NOW.minusSeconds(300)), + ticket(entity.getId(), "CHG011", "APPROVED", NOW.minusSeconds(10)))); + + var view = service.get(entity.getId(), ORG, SUBMITTER, Set.of()); + + assertThat(view.changeTicketRequired()).isTrue(); + assertThat(view.changeTicket().externalKey()).isEqualTo("CHG010"); + assertThat(view.changeTicket().ticketSystem()).isEqualTo("SERVICENOW"); + } + + @Test + void getWithoutTicketsOrFlagCarriesNoChangeTicket() { + var entity = existing(QueryStatus.PENDING_REVIEW); + when(requestRepository.findByIdAndOrganizationId(entity.getId(), ORG)) + .thenReturn(Optional.of(entity)); + when(ticketRepository.findAllByDeploymentRequestId(entity.getId())).thenReturn(List.of()); + + var view = service.get(entity.getId(), ORG, SUBMITTER, Set.of()); + + assertThat(view.changeTicketRequired()).isFalse(); + assertThat(view.changeTicket()).isNull(); + } + + @Test + void aMissingEnvironmentReportsNoChangeTicketRequirement() { + var entity = existing(QueryStatus.PENDING_REVIEW); + when(environmentRepository.findById(environment.getId())).thenReturn(Optional.empty()); + when(requestRepository.findByIdAndOrganizationId(entity.getId(), ORG)) + .thenReturn(Optional.of(entity)); + + assertThat(service.get(entity.getId(), ORG, SUBMITTER, Set.of()).changeTicketRequired()) + .isFalse(); + } + // ---- get ---------------------------------------------------------------------------------- @Test @@ -721,6 +802,23 @@ private static DeploymentPipelineEntity pipeline() { return entity; } + private static DeploymentTicketEntity ticket(UUID requestId, String key, String decision, + Instant createdAt) { + var entity = new DeploymentTicketEntity(); + entity.setId(UUID.randomUUID()); + entity.setOrganizationId(ORG); + entity.setDeploymentRequestId(requestId); + entity.setChannelId(UUID.randomUUID()); + entity.setTicketSystem("SERVICENOW"); + entity.setTriggerEvent("DEPLOYMENT_SUBMITTED"); + entity.setExternalId("sys-" + key); + entity.setExternalKey(key); + entity.setStatus("New"); + entity.setDecision(decision); + entity.setCreatedAt(createdAt); + return entity; + } + private static DeploymentEnvironmentEntity environment() { var entity = new DeploymentEnvironmentEntity(); entity.setId(UUID.randomUUID()); diff --git a/backend/src/test/java/com/bablsoft/accessflow/deploygov/internal/web/DeploygovWebModelsTest.java b/backend/src/test/java/com/bablsoft/accessflow/deploygov/internal/web/DeploygovWebModelsTest.java index 68b069976..dca5b0756 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/deploygov/internal/web/DeploygovWebModelsTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/deploygov/internal/web/DeploygovWebModelsTest.java @@ -11,6 +11,7 @@ import com.bablsoft.accessflow.deploygov.api.DeploymentOutcome; import com.bablsoft.accessflow.deploygov.api.DeploymentPipelineView; import com.bablsoft.accessflow.deploygov.api.DeploymentRequestView; +import com.bablsoft.accessflow.deploygov.api.DeploymentTicketView; import com.bablsoft.accessflow.deploygov.api.DeploymentReviewDecisionView; import com.bablsoft.accessflow.deploygov.api.DeploymentRoutingAction; import com.bablsoft.accessflow.deploygov.api.DeploymentRoutingConditions; @@ -18,6 +19,9 @@ import com.bablsoft.accessflow.deploygov.api.FreezeBehavior; import com.bablsoft.accessflow.deploygov.api.PipelineProvider; import org.junit.jupiter.api.Test; +import com.fasterxml.jackson.annotation.JsonInclude; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.PropertyNamingStrategies; import java.time.Instant; import java.time.LocalTime; @@ -300,6 +304,63 @@ void deploymentRequestResponseMapsTheViewIncludingDecisions() { assertThat(DeploymentRequestResponse.from(view, true).canReview()).isTrue(); } + @Test + void deploymentRequestResponseMapsTheChangeTicketSummary() { + var ticket = new DeploymentTicketView(UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID(), + UUID.randomUUID(), "JIRA", "DEPLOYMENT_SUBMITTED", "10001", "OPS-7", + "https://jira.example.com/browse/OPS-7", "Done", "Approved", "APPROVED", + Instant.now(), Instant.now(), Instant.now()); + var view = new DeploymentRequestView(UUID.randomUUID(), UUID.randomUUID(), "p", + PipelineProvider.GENERIC, UUID.randomUUID(), "prod", UUID.randomUUID(), null, + "1.0.0", null, null, null, null, null, QueryStatus.APPROVED, + SubmissionReason.USER_SUBMITTED, null, null, null, null, null, 1, null, null, null, + null, Instant.now(), null, null, null, true, ticket); + + var response = DeploymentRequestResponse.from(view); + + assertThat(response.changeTicketRequired()).isTrue(); + assertThat(response.changeTicket()).isEqualTo(new DeploymentChangeTicketResponse("JIRA", + "OPS-7", "https://jira.example.com/browse/OPS-7", "Done", "Approved", "APPROVED")); + assertThat(DeploymentChangeTicketResponse.from(null)).isNull(); + } + + @Test + void deploymentRequestWireShapeCarriesTheChangeTicketInSnakeCase() { + // Mirrors spring.jackson: SNAKE_CASE naming, nulls omitted. + var mapper = JsonMapper.builder() + .propertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE) + .changeDefaultPropertyInclusion(incl -> incl.withValueInclusion( + JsonInclude.Include.NON_NULL)) + .build(); + var ticket = new DeploymentTicketView(UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID(), + UUID.randomUUID(), "SERVICENOW", "DEPLOYMENT_SUBMITTED", "sys-1", "INC0010042", + null, "New", null, null, null, Instant.now(), Instant.now()); + var flagged = new DeploymentRequestView(UUID.randomUUID(), UUID.randomUUID(), "p", + PipelineProvider.GENERIC, UUID.randomUUID(), "prod", UUID.randomUUID(), null, + "1.0.0", null, null, null, null, null, QueryStatus.PENDING_REVIEW, + SubmissionReason.USER_SUBMITTED, null, null, null, null, null, 1, null, null, null, + null, Instant.now(), null, null, null, true, ticket); + + var json = mapper.readTree(mapper.writeValueAsString(DeploymentRequestResponse.from(flagged))); + + assertThat(json.get("change_ticket_required").asBoolean()).isTrue(); + var wire = json.get("change_ticket"); + assertThat(wire.get("system").asString()).isEqualTo("SERVICENOW"); + assertThat(wire.get("external_key").asString()).isEqualTo("INC0010042"); + assertThat(wire.get("status").asString()).isEqualTo("New"); + assertThat(wire.has("decision")).isFalse(); + assertThat(wire.has("url")).isFalse(); + + var unflagged = new DeploymentRequestView(UUID.randomUUID(), UUID.randomUUID(), "p", + PipelineProvider.GENERIC, UUID.randomUUID(), "dev", UUID.randomUUID(), null, + "1.0.0", null, null, null, null, null, QueryStatus.PENDING_REVIEW, + SubmissionReason.USER_SUBMITTED, null, null, null, null, null, 1, null, null, null, + null, Instant.now(), null); + var plain = mapper.readTree(mapper.writeValueAsString(DeploymentRequestResponse.from(unflagged))); + assertThat(plain.get("change_ticket_required").asBoolean()).isFalse(); + assertThat(plain.has("change_ticket")).isFalse(); + } + @Test void deploymentRequestPageResponseMapsContent() { var view = new DeploymentRequestView(UUID.randomUUID(), UUID.randomUUID(), "p", diff --git a/backend/src/test/java/com/bablsoft/accessflow/deploygov/internal/web/DeploymentGateControllerTest.java b/backend/src/test/java/com/bablsoft/accessflow/deploygov/internal/web/DeploymentGateControllerTest.java index 7da19a044..f0306c486 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/deploygov/internal/web/DeploymentGateControllerTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/deploygov/internal/web/DeploymentGateControllerTest.java @@ -76,7 +76,7 @@ void gateResponseCarriesTheChangeTicket() { var response = controller.gate(null, null, null, requestId, auth()); assertThat(response.changeTicketRequired()).isTrue(); - assertThat(response.changeTicket()).isEqualTo(new DeploymentGateResponse.ChangeTicket( + assertThat(response.changeTicket()).isEqualTo(new DeploymentChangeTicketResponse( "SERVICENOW", "CHG0012345", "https://acme.example/chg", "Implement", null, "APPROVED")); } diff --git a/docs/04-api-spec.md b/docs/04-api-spec.md index 1fe3167d1..b5df7a7da 100644 --- a/docs/04-api-spec.md +++ b/docs/04-api-spec.md @@ -9070,6 +9070,27 @@ the trigger, `confirm-execution` and `outcome` — always reports `false`, exact always `[]` on the list. An id the caller cannot see never reaches the flag — the visibility `404` fires first. +**Change ticket on every request body (#994).** Each body this resource returns — list, detail, +trigger, `confirm-execution`, `outcome` — carries `change_ticket_required` (mirrors the environment's +flag; `false` when the environment row is gone) and `change_ticket`, the linked ServiceNow / Jira +ticket that explains the request's change-ticket state, chosen exactly as the gate does: the newest +rejected ticket, else the newest approved one, else the newest linked one. It has the gate's shape +(`system`, `external_key`, `url`, `status`, `resolution`, `decision` — `APPROVED`, `REJECTED` or +absent until the ticketing system's callback decides it; nulls are not serialised) and is omitted when +no ticket is linked. On ServiceNow the ticket is an incident. Unlike the gate, it is present at every status, so a reviewer +sees the ticket while the request is still `PENDING_REVIEW`. The list loads tickets for the whole +page in one query. + +```json +"change_ticket_required": true, +"change_ticket": { + "system": "SERVICENOW", + "external_key": "INC0010042", + "url": "https://acme.service-now.com/nav_to.do?uri=incident.do?sys_id=…", + "status": "New" +} +``` + `SubmitDeploymentRequestRequest` fields: `pipelineId` (required), `environment` (required, ≤255 — the environment **name** within the pipeline, because a CI job knows names, not UUIDs), `version` (required, ≤255 — the semantic artifact version being deployed, e.g. `2.4.1`), diff --git a/docs/18-deployment-governance.md b/docs/18-deployment-governance.md index 4cbbb48df..95b1e95c8 100644 --- a/docs/18-deployment-governance.md +++ b/docs/18-deployment-governance.md @@ -308,6 +308,19 @@ row is read before the break-glass shortcut, so a request whose environment was releasable — break-glass included. A [decision trace](#10-decision-trace-af-967) for a flagged environment always reports *not releasable*: a hypothetical request has no ticket yet. +**In the web UI (#994).** The `change_ticket_required` switch sits on the pipeline settings +**Environments** tab, after *Break-glass*. Its help text warns that a ServiceNow or Jira channel must +enable the *Deployment submitted* trigger (`DEPLOYMENT_SUBMITTED`, a checkbox on both channel forms), +or the gate never opens. The deployment detail page shows a **Change ticket** card: the key, linked to +the ticketing system, plus the system, the status (with its resolution) and an *approved* / *rejected* +tag. On a flagged environment with no ticket yet, the card reads "no change ticket linked yet". When an +approved, unfrozen, not-releasable request on a flagged environment has no approved ticket, the gate +banner reads "waiting for the change ticket" (or "the change ticket was rejected"); that message wins +over the scheduled-time one. `/deployments` adds a **Ticket** column. The card and the column read the +`change_ticket` summary on the deployment request body (see `docs/04-api-spec.md` → "Deployment +requests"), not the gate, so the ticket shows while the request is still in review; the banner reads +the gate. + **`frozen` means any active window, `HOLD` or `REJECT`.** A `REJECT` window normally auto-rejects at submission, but a request approved *before* the window opened must not sail through mid-freeze. Break-glass requests skip the freeze check entirely — they already bypassed it, audibly, at diff --git a/e2e/helpers/deployments.ts b/e2e/helpers/deployments.ts index 50c5c4509..99b48208d 100644 --- a/e2e/helpers/deployments.ts +++ b/e2e/helpers/deployments.ts @@ -57,6 +57,8 @@ export async function createDeploymentEnvironmentViaApi( tags?: string[]; /** Binds the datasource schema changes land on (#877); omitted → deploy-only. */ datasourceId?: string; + /** Releases wait for an approved ServiceNow / Jira change ticket (#990). */ + changeTicketRequired?: boolean; }, ): Promise { const res = await request.post( @@ -71,6 +73,9 @@ export async function createDeploymentEnvironmentViaApi( ? {} : { required_approvals: options.requiredApprovals }), ...(options.requireReview === undefined ? {} : { require_review: options.requireReview }), + ...(options.changeTicketRequired === undefined + ? {} + : { change_ticket_required: options.changeTicketRequired }), }, }, ); @@ -179,6 +184,10 @@ export interface DeploymentGateResult { status: string; releasable: boolean; frozen: boolean; + /** Mirrors the environment flag (#993). */ + change_ticket_required: boolean; + /** The backend omits nulls, so an unlinked ticket arrives absent. */ + change_ticket?: { external_key: string; decision?: string | null } | null; } /** Reads the fail-closed deployment gate for a request. */ diff --git a/e2e/tests/admin-notification-channels.spec.ts b/e2e/tests/admin-notification-channels.spec.ts index 165476408..398a92e24 100644 --- a/e2e/tests/admin-notification-channels.spec.ts +++ b/e2e/tests/admin-notification-channels.spec.ts @@ -308,6 +308,8 @@ test.describe.serial('/admin/notifications — channel CRUD + test', () => { await dialog.getByLabel('Password').fill(`snow-${UNIQUE_SUFFIX}`); await dialog.getByRole('checkbox', { name: 'Query rejected' }).check(); await dialog.getByRole('checkbox', { name: 'Review timeout', exact: true }).check(); + // Opens a change ticket for deployments on change-ticket environments (#991, #994). + await dialog.getByRole('checkbox', { name: /^Deployment submitted/ }).check(); const createResponsePromise = page.waitForResponse( (r) => @@ -318,6 +320,10 @@ test.describe.serial('/admin/notifications — channel CRUD + test', () => { await dialog.getByRole('button', { name: 'Create channel' }).click(); const createResponse = await createResponsePromise; expect(createResponse.status()).toBe(201); + const sent = createResponse.request().postDataJSON() as { config: { triggers: string[] } }; + expect(sent.config.triggers).toEqual( + expect.arrayContaining(['QUERY_REJECTED', 'REVIEW_TIMEOUT', 'DEPLOYMENT_SUBMITTED']), + ); const body = (await createResponse.json()) as { id: string; name: string }; created.set('servicenow', { id: body.id, name: body.name }); expect(body.name).toBe(SERVICENOW_NAME); diff --git a/e2e/tests/deployment-pipelines.spec.ts b/e2e/tests/deployment-pipelines.spec.ts index e0b1ce386..5c927032b 100644 --- a/e2e/tests/deployment-pipelines.spec.ts +++ b/e2e/tests/deployment-pipelines.spec.ts @@ -149,15 +149,29 @@ test.describe.serial('deployment pipeline administration (#696)', () => { new URL(r.url()).pathname.endsWith(`/deployment-pipelines/${pipeline.id}/environments`), { timeout: 15_000 }, ); + // Production releases wait for an approved change ticket (#994); the switch says why. + await expect(prodDialog.getByText(/must enable the deployment trigger/)).toBeVisible(); + await prodDialog.getByRole('switch', { name: 'Change ticket' }).click(); await prodDialog.getByRole('button', { name: 'Save' }).click(); const prodRaw = await prodResponse; expect(prodRaw.status()).toBe(201); - const prodBody = (await prodRaw.json()) as { sort_order: number; datasource_id: string | null }; + const prodBody = (await prodRaw.json()) as { + sort_order: number; + datasource_id: string | null; + change_ticket_required: boolean; + }; expect(prodBody.sort_order).toBe(1); expect(prodBody.datasource_id).toBe(datasource.id); + expect(prodBody.change_ticket_required).toBe(true); await expect( envPanel.locator('.ant-table-row', { hasText: 'production' }), ).toContainText(datasource.name); + await expect(envPanel.getByRole('columnheader', { name: 'Change ticket' })).toBeVisible(); + // Production: review + change ticket; staging: review only. + await expect( + envPanel.locator('.ant-table-row', { hasText: 'production' }).getByText('✓'), + ).toHaveCount(2); + await expect(stagingRow.getByText('✓')).toHaveCount(1); // Editing the bound environment onto a taken position is refused with the server's 409 detail. await envPanel diff --git a/e2e/tests/deployment-review.spec.ts b/e2e/tests/deployment-review.spec.ts index 9a217490d..567867944 100644 --- a/e2e/tests/deployment-review.spec.ts +++ b/e2e/tests/deployment-review.spec.ts @@ -56,6 +56,13 @@ test.describe.serial('deployment governance review flow (#696)', () => { name: 'production', requiredApprovals: 1, }); + // A change-ticket environment (#990): no ServiceNow / Jira is reachable from the stack, so + // releases here stay held — exactly the "waiting for the change ticket" state (#994). + await createDeploymentEnvironmentViaApi(request, adminAccessToken, pipeline.id, { + name: 'regulated', + requiredApprovals: 1, + changeTicketRequired: true, + }); // The submitter is a non-admin REVIEWER: admins bypass the can_trigger check, so they // cannot stand in here — and REVIEWER (not ANALYST) is what makes the self-approval ban @@ -197,6 +204,37 @@ test.describe.serial('deployment governance review flow (#696)', () => { } }); + test('an approved deployment on a change-ticket environment waits for the ticket (#994)', async ({ + page, + request, + }) => { + if (!pipeline) throw new Error('pipeline not created in beforeAll'); + + const version = `3.0.0-${Date.now()}`; + const triggered = await triggerDeploymentViaApi(request, submitterToken, { + pipelineId: pipeline.id, + environment: 'regulated', + version, + externalRunId: `run-ticket-${Date.now()}`, + }); + await waitForDeploymentStatus(request, adminAccessToken, triggered.id, 'PENDING_REVIEW'); + const decision = await approveDeploymentViaApi(request, adminAccessToken, triggered.id); + expect(decision.ok).toBe(true); + + const gate = await getDeploymentGateViaApi(request, adminAccessToken, triggered.id); + expect(gate.status).toBe('APPROVED'); + expect(gate.releasable).toBe(false); + expect(gate.frozen).toBe(false); + expect(gate.change_ticket_required).toBe(true); + expect(gate.change_ticket ?? null).toBeNull(); + + await login(page, ADMIN_EMAIL, ADMIN_PASSWORD); + await page.goto(`/deployments/${triggered.id}`); + await expect(page.getByText(/waiting for the change ticket/)).toBeVisible({ timeout: 15_000 }); + await expect(page.getByText(/No change ticket linked yet/)).toBeVisible(); + await expect(page.getByText(/Approved and releasable/)).toHaveCount(0); + }); + // #770: the queue row navigates to the detail page, so the decision has to be reachable there // too — a reviewer who opens a deployment to read its metadata must not have to go back. test('a reviewer approves from the deployment detail page reached through the queue row', async ({ diff --git a/frontend/src/components/deployments/ChangeTicketLink.test.tsx b/frontend/src/components/deployments/ChangeTicketLink.test.tsx new file mode 100644 index 000000000..a93665de8 --- /dev/null +++ b/frontend/src/components/deployments/ChangeTicketLink.test.tsx @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import '@/i18n'; +import { ChangeTicketLink } from './ChangeTicketLink'; + +describe('ChangeTicketLink', () => { + it('links the key out in a new tab without an opener and tags the decision', () => { + render( + , + ); + + const link = screen.getByText('CHG0001').closest('a'); + expect(link).toHaveAttribute('href', 'https://acme.service-now.com/x'); + expect(link).toHaveAttribute('target', '_blank'); + expect(link).toHaveAttribute('rel', 'noopener noreferrer'); + expect(screen.getByText('rejected')).toBeInTheDocument(); + }); + + it('renders a ticket without a URL or decision as plain text', () => { + render( + , + ); + + expect(screen.getByText('OPS-1').closest('a')).toBeNull(); + expect(screen.queryByText('approved')).toBeNull(); + expect(screen.queryByText('rejected')).toBeNull(); + }); +}); diff --git a/frontend/src/components/deployments/ChangeTicketLink.tsx b/frontend/src/components/deployments/ChangeTicketLink.tsx new file mode 100644 index 000000000..7be7daad8 --- /dev/null +++ b/frontend/src/components/deployments/ChangeTicketLink.tsx @@ -0,0 +1,32 @@ +import { Tag } from 'antd'; +import { useTranslation } from 'react-i18next'; +import { deploymentTicketDecisionLabel } from '@/utils/enumLabels'; +import { deploymentTicketDecisionColor } from '@/utils/statusColors'; +import type { DeploymentTicketRef } from '@/types/api'; + +/** A deployment's change-ticket key, linked out when the system gave a URL, plus its decision (#994). */ +export function ChangeTicketLink({ ticket }: { ticket: DeploymentTicketRef }) { + const { t } = useTranslation(); + const color = ticket.decision ? deploymentTicketDecisionColor(ticket.decision) : null; + return ( + + {ticket.url ? ( + + {ticket.external_key} + + ) : ( + {ticket.external_key} + )} + {ticket.decision && color && ( + + {deploymentTicketDecisionLabel(t, ticket.decision)} + + )} + + ); +} diff --git a/frontend/src/components/deployments/PipelineEnvironmentsTab.test.tsx b/frontend/src/components/deployments/PipelineEnvironmentsTab.test.tsx index 66e308f38..99a3cc139 100644 --- a/frontend/src/components/deployments/PipelineEnvironmentsTab.test.tsx +++ b/frontend/src/components/deployments/PipelineEnvironmentsTab.test.tsx @@ -57,6 +57,7 @@ const staging: DeploymentEnvironment = { required_approvals: 2, review_plan_id: 'plan-1', allow_break_glass: false, + change_ticket_required: false, datasource_id: null, created_at: '2026-08-01T10:00:00Z', }; @@ -71,6 +72,7 @@ const production: DeploymentEnvironment = { required_approvals: null, review_plan_id: null, allow_break_glass: true, + change_ticket_required: true, datasource_id: 'ds-1', created_at: '2026-08-01T10:00:00Z', }; @@ -162,10 +164,55 @@ describe('PipelineEnvironmentsTab', () => { required_approvals: null, review_plan_id: null, allow_break_glass: false, + change_ticket_required: false, datasource_id: null, }); }); + it('creates a change-ticket environment when the switch is on and explains the trigger', async () => { + render(wrap()); + await screen.findByText('staging'); + + fireEvent.click(screen.getByRole('button', { name: /add environment/i })); + await screen.findByRole('dialog'); + fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'prod-eu' } }); + expect(screen.getByText(/must enable the deployment trigger/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole('switch', { name: 'Change ticket' })); + + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + await waitFor(() => expect(createDeploymentEnvironment).toHaveBeenCalledTimes(1)); + expect(createDeploymentEnvironment.mock.calls[0]![1]).toEqual( + expect.objectContaining({ name: 'prod-eu', change_ticket_required: true }), + ); + }); + + it('prefills the change-ticket switch on edit and sends it back when turned off', async () => { + render(wrap()); + await screen.findByText('staging'); + + fireEvent.click(screen.getAllByRole('button', { name: 'Edit' })[1]!); + await screen.findByRole('dialog'); + await waitFor(() => expect(screen.getByLabelText('Name')).toHaveValue('production')); + const toggle = screen.getByRole('switch', { name: 'Change ticket' }); + expect(toggle).toBeChecked(); + fireEvent.click(toggle); + + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + await waitFor(() => expect(updateDeploymentEnvironment).toHaveBeenCalledTimes(1)); + expect(updateDeploymentEnvironment.mock.calls[0]![2]).toEqual( + expect.objectContaining({ change_ticket_required: false }), + ); + }); + + it('renders the change-ticket column', async () => { + render(wrap()); + await screen.findByText('staging'); + + expect(screen.getByText('Change ticket').closest('th')).not.toBeNull(); + // Both rows require review; production adds break-glass and the change ticket. + expect(screen.getAllByText('✓')).toHaveLength(4); + }); + it('renders the bound datasource name and the deploy-only placeholder', async () => { render(wrap()); diff --git a/frontend/src/components/deployments/PipelineEnvironmentsTab.tsx b/frontend/src/components/deployments/PipelineEnvironmentsTab.tsx index bc2714eb2..214867dd2 100644 --- a/frontend/src/components/deployments/PipelineEnvironmentsTab.tsx +++ b/frontend/src/components/deployments/PipelineEnvironmentsTab.tsx @@ -40,6 +40,7 @@ interface EnvironmentFormValues { required_approvals?: number | null; review_plan_id?: string | null; allow_break_glass: boolean; + change_ticket_required: boolean; datasource_id?: string | null; } @@ -78,6 +79,7 @@ export function PipelineEnvironmentsTab({ pipelineId }: { pipelineId: string }) required_approvals: null, review_plan_id: null, allow_break_glass: false, + change_ticket_required: false, datasource_id: null, }); } else { @@ -89,6 +91,7 @@ export function PipelineEnvironmentsTab({ pipelineId }: { pipelineId: string }) required_approvals: modalFor.required_approvals, review_plan_id: modalFor.review_plan_id, allow_break_glass: modalFor.allow_break_glass, + change_ticket_required: modalFor.change_ticket_required, datasource_id: modalFor.datasource_id, }); } @@ -115,6 +118,7 @@ export function PipelineEnvironmentsTab({ pipelineId }: { pipelineId: string }) required_approvals: values.required_approvals ?? null, review_plan_id: values.review_plan_id ?? null, allow_break_glass: values.allow_break_glass, + change_ticket_required: values.change_ticket_required, datasource_id: values.datasource_id ?? null, }); } @@ -130,6 +134,7 @@ export function PipelineEnvironmentsTab({ pipelineId }: { pipelineId: string }) review_plan_id: values.review_plan_id ?? null, clear_review_plan: values.review_plan_id == null, allow_break_glass: values.allow_break_glass, + change_ticket_required: values.change_ticket_required, datasource_id: values.datasource_id ?? null, clear_datasource: values.datasource_id == null, }); @@ -216,6 +221,13 @@ export function PipelineEnvironmentsTab({ pipelineId }: { pipelineId: string }) align: 'center' as const, render: (v: boolean) => (v ? '✓' : '—'), }, + { + title: t('deploygov.settings.envChangeTicketRequired'), + dataIndex: 'change_ticket_required', + width: 120, + align: 'center' as const, + render: (v: boolean) => (v ? '✓' : '—'), + }, { title: '', key: 'actions', @@ -372,6 +384,14 @@ export function PipelineEnvironmentsTab({ pipelineId }: { pipelineId: string }) > + + + diff --git a/frontend/src/locales/de.json b/frontend/src/locales/de.json index b86cbf1a5..65035eff4 100644 --- a/frontend/src/locales/de.json +++ b/frontend/src/locales/de.json @@ -2039,6 +2039,7 @@ "label_ticketing_trigger_query_rejected": "Abfrage abgelehnt", "label_ticketing_trigger_review_timeout": "Review-Timeout", "label_ticketing_trigger_query_escalated": "Routing-Richtlinien-Eskalation", + "label_ticketing_trigger_deployment_submitted": "Deployment eingereicht (öffnet ein Change-Ticket in Change-Ticket-Umgebungen)", "label_ticketing_bidirectional_sync": "Bidirektionale Status-Synchronisierung", "help_ticketing_bidirectional_sync": "Signierte Status-Webhooks vom Ticketing-System akzeptieren; eine passende Lösung kann die ausstehende Abfrage genehmigen oder ablehnen.", "label_ticketing_webhook_secret": "Webhook-Geheimnis", @@ -3756,6 +3757,10 @@ "ROLLED_BACK": "zurückgerollt", "SUCCEEDED": "erfolgreich" }, + "deployment_ticket_decision": { + "APPROVED": "genehmigt", + "REJECTED": "abgelehnt" + }, "pipeline_provider": { "GITHUB_ACTIONS": "GitHub Actions", "GITLAB_CI": "GitLab CI", @@ -5535,6 +5540,7 @@ "status": "Status", "risk": "KI-Risiko", "outcome": "Ergebnis", + "ticket": "Ticket", "created": "Eingereicht", "filterAllStatuses": "Alle Status", "filterAllPipelines": "Alle Pipelines", @@ -5566,6 +5572,10 @@ "aiSummary": "KI-Analyse", "approvals": "Genehmigungen", "approvalsProgress": "{{granted}} von {{required}} Genehmigungen", + "changeTicket": "Change-Ticket", + "changeTicketStatus": "Status: {{status}}", + "changeTicketStatusResolution": "Status: {{status}} ({{resolution}})", + "changeTicketNone": "Noch kein Change-Ticket verknüpft. Releases in dieser Umgebung warten auf ein genehmigtes ServiceNow- oder Jira-Change-Ticket.", "decisions": "Entscheidungen", "decision": "Entscheidung", "reviewer": "Prüfer", @@ -5577,6 +5587,8 @@ "bannerFrozen": "Durch ein Freeze-Fenster zurückgehalten — das Gate bleibt geschlossen, bis das Fenster endet.", "bannerFrozenReason": "Durch ein Freeze-Fenster zurückgehalten: {{reason}}", "bannerScheduled": "Geplant — das Gate öffnet am {{when}}.", + "bannerWaitingTicket": "Genehmigt — wartet auf das Change-Ticket. Das Gate öffnet sich, sobald das verknüpfte ServiceNow- oder Jira-Ticket genehmigt ist.", + "bannerTicketRejected": "Genehmigt, aber das Change-Ticket wurde abgelehnt — das Gate bleibt geschlossen.", "cancel": "Deployment abbrechen", "cancelConfirmTitle": "Dieses Deployment abbrechen?", "cancelConfirmBody": "Das Pipeline-Gate meldet dann nicht freigebbar. Dies kann nicht rückgängig gemacht werden.", @@ -5748,6 +5760,8 @@ "envRequiredApprovals": "Erforderliche Genehmigungen", "envReviewPlan": "Prüfplan", "envAllowBreakGlass": "Break-Glass", + "envChangeTicketRequired": "Change-Ticket", + "envChangeTicketRequiredHelp": "Releases in dieser Umgebung warten auf ein genehmigtes ServiceNow- oder Jira-Change-Ticket. Ein ServiceNow- oder Jira-Kanal muss den Deployment-Trigger aktivieren, sonst öffnet sich das Gate nie.", "envCreateTitle": "Umgebung hinzufügen", "envEditTitle": "Umgebung bearbeiten", "envCreated": "Umgebung erstellt", diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index a39a7b939..ca936a2b0 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -2098,6 +2098,7 @@ "label_ticketing_trigger_query_rejected": "Query rejected", "label_ticketing_trigger_review_timeout": "Review timeout", "label_ticketing_trigger_query_escalated": "Routing-policy escalation", + "label_ticketing_trigger_deployment_submitted": "Deployment submitted (opens a change ticket on change-ticket environments)", "label_ticketing_bidirectional_sync": "Bi-directional status sync", "help_ticketing_bidirectional_sync": "Accept signed status webhooks from the ticketing system; a matching resolution can approve or reject the pending query.", "label_ticketing_webhook_secret": "Webhook secret", @@ -3781,6 +3782,10 @@ "ROLLED_BACK": "rolled back", "SUCCEEDED": "succeeded" }, + "deployment_ticket_decision": { + "APPROVED": "approved", + "REJECTED": "rejected" + }, "pipeline_provider": { "GITHUB_ACTIONS": "GitHub Actions", "GITLAB_CI": "GitLab CI", @@ -5535,6 +5540,7 @@ "status": "Status", "risk": "AI risk", "outcome": "Outcome", + "ticket": "Ticket", "created": "Submitted", "filterAllStatuses": "All statuses", "filterAllPipelines": "All pipelines", @@ -5566,6 +5572,10 @@ "aiSummary": "AI analysis", "approvals": "Approvals", "approvalsProgress": "{{granted}} of {{required}} approvals", + "changeTicket": "Change ticket", + "changeTicketStatus": "Status: {{status}}", + "changeTicketStatusResolution": "Status: {{status}} ({{resolution}})", + "changeTicketNone": "No change ticket linked yet. Releases on this environment wait for an approved ServiceNow or Jira change ticket.", "decisions": "Decisions", "decision": "Decision", "reviewer": "Reviewer", @@ -5577,6 +5587,8 @@ "bannerFrozen": "Held by a freeze window — the gate stays closed until the window ends.", "bannerFrozenReason": "Held by a freeze window: {{reason}}", "bannerScheduled": "Scheduled — the gate opens at {{when}}.", + "bannerWaitingTicket": "Approved — waiting for the change ticket. The gate opens once the linked ServiceNow or Jira ticket is approved.", + "bannerTicketRejected": "Approved, but the change ticket was rejected — the gate stays closed.", "cancel": "Cancel deployment", "cancelConfirmTitle": "Cancel this deployment?", "cancelConfirmBody": "The pipeline gate will answer not releasable. This cannot be undone.", @@ -5748,6 +5760,8 @@ "envRequiredApprovals": "Required approvals", "envReviewPlan": "Review plan", "envAllowBreakGlass": "Break-glass", + "envChangeTicketRequired": "Change ticket", + "envChangeTicketRequiredHelp": "Releases on this environment wait for an approved ServiceNow or Jira change ticket. A ServiceNow or Jira channel must enable the deployment trigger, or the gate never opens.", "envCreateTitle": "Add environment", "envEditTitle": "Edit environment", "envCreated": "Environment created", diff --git a/frontend/src/locales/es.json b/frontend/src/locales/es.json index e9f414f12..14efbfda8 100644 --- a/frontend/src/locales/es.json +++ b/frontend/src/locales/es.json @@ -2039,6 +2039,7 @@ "label_ticketing_trigger_query_rejected": "Consulta rechazada", "label_ticketing_trigger_review_timeout": "Tiempo de espera de revisión", "label_ticketing_trigger_query_escalated": "Escalado por política de enrutamiento", + "label_ticketing_trigger_deployment_submitted": "Despliegue enviado (abre un ticket de cambio en entornos con ticket de cambio)", "label_ticketing_bidirectional_sync": "Sincronización de estado bidireccional", "help_ticketing_bidirectional_sync": "Aceptar webhooks de estado firmados del sistema de ticketing; una resolución coincidente puede aprobar o rechazar la consulta pendiente.", "label_ticketing_webhook_secret": "Secreto del webhook", @@ -3756,6 +3757,10 @@ "ROLLED_BACK": "revertido", "SUCCEEDED": "exitoso" }, + "deployment_ticket_decision": { + "APPROVED": "aprobado", + "REJECTED": "rechazado" + }, "pipeline_provider": { "GITHUB_ACTIONS": "GitHub Actions", "GITLAB_CI": "GitLab CI", @@ -5535,6 +5540,7 @@ "status": "Estado", "risk": "Riesgo IA", "outcome": "Resultado", + "ticket": "Ticket", "created": "Enviado", "filterAllStatuses": "Todos los estados", "filterAllPipelines": "Todos los pipelines", @@ -5566,6 +5572,10 @@ "aiSummary": "Análisis de IA", "approvals": "Aprobaciones", "approvalsProgress": "{{granted}} de {{required}} aprobaciones", + "changeTicket": "Ticket de cambio", + "changeTicketStatus": "Estado: {{status}}", + "changeTicketStatusResolution": "Estado: {{status}} ({{resolution}})", + "changeTicketNone": "Aún no hay ningún ticket de cambio vinculado. Las publicaciones en este entorno esperan un ticket de cambio aprobado de ServiceNow o Jira.", "decisions": "Decisiones", "decision": "Decisión", "reviewer": "Revisor", @@ -5577,6 +5587,8 @@ "bannerFrozen": "Retenido por una ventana de congelación: la puerta permanece cerrada hasta que termine la ventana.", "bannerFrozenReason": "Retenido por una ventana de congelación: {{reason}}", "bannerScheduled": "Programado: la puerta se abre el {{when}}.", + "bannerWaitingTicket": "Aprobado — esperando el ticket de cambio. La compuerta se abre cuando se aprueba el ticket vinculado de ServiceNow o Jira.", + "bannerTicketRejected": "Aprobado, pero el ticket de cambio fue rechazado — la compuerta permanece cerrada.", "cancel": "Cancelar despliegue", "cancelConfirmTitle": "¿Cancelar este despliegue?", "cancelConfirmBody": "La puerta del pipeline responderá no liberable. Esta acción no se puede deshacer.", @@ -5748,6 +5760,8 @@ "envRequiredApprovals": "Aprobaciones requeridas", "envReviewPlan": "Plan de revisión", "envAllowBreakGlass": "Break-glass", + "envChangeTicketRequired": "Ticket de cambio", + "envChangeTicketRequiredHelp": "Las publicaciones en este entorno esperan un ticket de cambio aprobado de ServiceNow o Jira. Un canal de ServiceNow o Jira debe activar el disparador de despliegue, o la compuerta nunca se abre.", "envCreateTitle": "Añadir entorno", "envEditTitle": "Editar entorno", "envCreated": "Entorno creado", diff --git a/frontend/src/locales/fr.json b/frontend/src/locales/fr.json index f5e680311..69644aa7e 100644 --- a/frontend/src/locales/fr.json +++ b/frontend/src/locales/fr.json @@ -2039,6 +2039,7 @@ "label_ticketing_trigger_query_rejected": "Requête rejetée", "label_ticketing_trigger_review_timeout": "Délai de revue dépassé", "label_ticketing_trigger_query_escalated": "Escalade par politique de routage", + "label_ticketing_trigger_deployment_submitted": "Déploiement soumis (ouvre un ticket de changement sur les environnements concernés)", "label_ticketing_bidirectional_sync": "Synchronisation de statut bidirectionnelle", "help_ticketing_bidirectional_sync": "Accepter les webhooks de statut signés du système de ticketing ; une résolution correspondante peut approuver ou rejeter la requête en attente.", "label_ticketing_webhook_secret": "Secret du webhook", @@ -3756,6 +3757,10 @@ "ROLLED_BACK": "annulé (rollback)", "SUCCEEDED": "réussi" }, + "deployment_ticket_decision": { + "APPROVED": "approuvé", + "REJECTED": "rejeté" + }, "pipeline_provider": { "GITHUB_ACTIONS": "GitHub Actions", "GITLAB_CI": "GitLab CI", @@ -5535,6 +5540,7 @@ "status": "Statut", "risk": "Risque IA", "outcome": "Résultat", + "ticket": "Ticket", "created": "Soumis", "filterAllStatuses": "Tous les statuts", "filterAllPipelines": "Tous les pipelines", @@ -5566,6 +5572,10 @@ "aiSummary": "Analyse IA", "approvals": "Approbations", "approvalsProgress": "{{granted}} sur {{required}} approbations", + "changeTicket": "Ticket de changement", + "changeTicketStatus": "Statut : {{status}}", + "changeTicketStatusResolution": "Statut : {{status}} ({{resolution}})", + "changeTicketNone": "Aucun ticket de changement lié pour l'instant. Les mises en production sur cet environnement attendent un ticket de changement ServiceNow ou Jira approuvé.", "decisions": "Décisions", "decision": "Décision", "reviewer": "Réviseur", @@ -5577,6 +5587,8 @@ "bannerFrozen": "Retenu par une fenêtre de gel — la porte reste fermée jusqu'à la fin de la fenêtre.", "bannerFrozenReason": "Retenu par une fenêtre de gel : {{reason}}", "bannerScheduled": "Planifié — la porte s'ouvre le {{when}}.", + "bannerWaitingTicket": "Approuvé — en attente du ticket de changement. La barrière s'ouvre dès que le ticket ServiceNow ou Jira lié est approuvé.", + "bannerTicketRejected": "Approuvé, mais le ticket de changement a été rejeté — la barrière reste fermée.", "cancel": "Annuler le déploiement", "cancelConfirmTitle": "Annuler ce déploiement ?", "cancelConfirmBody": "La porte du pipeline répondra non libérable. Cette action est irréversible.", @@ -5748,6 +5760,8 @@ "envRequiredApprovals": "Approbations requises", "envReviewPlan": "Plan de revue", "envAllowBreakGlass": "Break-glass", + "envChangeTicketRequired": "Ticket de changement", + "envChangeTicketRequiredHelp": "Les mises en production sur cet environnement attendent un ticket de changement ServiceNow ou Jira approuvé. Un canal ServiceNow ou Jira doit activer le déclencheur de déploiement, sinon la barrière ne s'ouvre jamais.", "envCreateTitle": "Ajouter un environnement", "envEditTitle": "Modifier l'environnement", "envCreated": "Environnement créé", diff --git a/frontend/src/locales/hy.json b/frontend/src/locales/hy.json index fbbf15cf0..a30fb4fe3 100644 --- a/frontend/src/locales/hy.json +++ b/frontend/src/locales/hy.json @@ -2039,6 +2039,7 @@ "label_ticketing_trigger_query_rejected": "Հարցումը մերժված է", "label_ticketing_trigger_review_timeout": "Վերանայման ժամկետի սպառում", "label_ticketing_trigger_query_escalated": "Երթուղավորման քաղաքականության էսկալացիա", + "label_ticketing_trigger_deployment_submitted": "Տեղակայումը ներկայացված է (բացում է փոփոխության տոմս համապատասխան միջավայրերում)", "label_ticketing_bidirectional_sync": "Կարգավիճակի երկկողմանի համաժամացում", "help_ticketing_bidirectional_sync": "Ընդունել ստորագրված կարգավիճակի webhook-ներ տոմսերի համակարգից․ համապատասխան լուծումը կարող է հաստատել կամ մերժել սպասվող հարցումը։", "label_ticketing_webhook_secret": "Webhook գաղտնի բանալի", @@ -3756,6 +3757,10 @@ "ROLLED_BACK": "հետ գլորված", "SUCCEEDED": "հաջողված" }, + "deployment_ticket_decision": { + "APPROVED": "հաստատված", + "REJECTED": "մերժված" + }, "pipeline_provider": { "GITHUB_ACTIONS": "GitHub Actions", "GITLAB_CI": "GitLab CI", @@ -5535,6 +5540,7 @@ "status": "Կարգավիճակ", "risk": "ԱԲ ռիսկ", "outcome": "Արդյունք", + "ticket": "Տոմս", "created": "Ներկայացվել է", "filterAllStatuses": "Բոլոր կարգավիճակները", "filterAllPipelines": "Բոլոր փիփլայնները", @@ -5566,6 +5572,10 @@ "aiSummary": "ԱԲ վերլուծություն", "approvals": "Հաստատումներ", "approvalsProgress": "{{granted}} / {{required}} հաստատում", + "changeTicket": "Փոփոխության տոմս", + "changeTicketStatus": "Կարգավիճակ՝ {{status}}", + "changeTicketStatusResolution": "Կարգավիճակ՝ {{status}} ({{resolution}})", + "changeTicketNone": "Փոփոխության տոմս դեռ կապված չէ։ Այս միջավայրում թողարկումները սպասում են ServiceNow-ի կամ Jira-ի հաստատված փոփոխության տոմսի։", "decisions": "Որոշումներ", "decision": "Որոշում", "reviewer": "Վերանայող", @@ -5577,6 +5587,8 @@ "bannerFrozen": "Պահվում է սառեցման պատուհանի կողմից — դարպասը փակ կմնա մինչև պատուհանի ավարտը։", "bannerFrozenReason": "Պահվում է սառեցման պատուհանի կողմից՝ {{reason}}", "bannerScheduled": "Պլանավորված է — դարպասը կբացվի {{when}}-ին։", + "bannerWaitingTicket": "Հաստատված է — սպասում է փոփոխության տոմսին։ Դարպասը բացվում է, երբ կապված ServiceNow-ի կամ Jira-ի տոմսը հաստատվի։", + "bannerTicketRejected": "Հաստատված է, բայց փոփոխության տոմսը մերժվել է — դարպասը մնում է փակ։", "cancel": "Չեղարկել տեղակայումը", "cancelConfirmTitle": "Չեղարկե՞լ այս տեղակայումը", "cancelConfirmBody": "Փիփլայնի դարպասը կպատասխանի «ոչ թողարկելի»։ Սա հնարավոր չէ հետարկել։", @@ -5748,6 +5760,8 @@ "envRequiredApprovals": "Պահանջվող հաստատումներ", "envReviewPlan": "Վերանայման պլան", "envAllowBreakGlass": "Break-glass", + "envChangeTicketRequired": "Փոփոխության տոմս", + "envChangeTicketRequiredHelp": "Այս միջավայրում թողարկումները սպասում են ServiceNow-ի կամ Jira-ի հաստատված փոփոխության տոմսի։ ServiceNow-ի կամ Jira-ի ալիքը պետք է միացնի տեղակայման տրիգերը, այլապես դարպասը երբեք չի բացվի։", "envCreateTitle": "Ավելացնել միջավայր", "envEditTitle": "Խմբագրել միջավայրը", "envCreated": "Միջավայրը ստեղծվեց", diff --git a/frontend/src/locales/ru.json b/frontend/src/locales/ru.json index 59a86a282..89bc67e18 100644 --- a/frontend/src/locales/ru.json +++ b/frontend/src/locales/ru.json @@ -2039,6 +2039,7 @@ "label_ticketing_trigger_query_rejected": "Запрос отклонён", "label_ticketing_trigger_review_timeout": "Тайм-аут проверки", "label_ticketing_trigger_query_escalated": "Эскалация по политике маршрутизации", + "label_ticketing_trigger_deployment_submitted": "Развертывание отправлено (открывает тикет изменения в соответствующих окружениях)", "label_ticketing_bidirectional_sync": "Двусторонняя синхронизация статусов", "help_ticketing_bidirectional_sync": "Принимать подписанные webhook-и статусов от тикетинг-системы; совпадающая резолюция может одобрить или отклонить ожидающий запрос.", "label_ticketing_webhook_secret": "Секрет webhook", @@ -3756,6 +3757,10 @@ "ROLLED_BACK": "откаченным", "SUCCEEDED": "успешным" }, + "deployment_ticket_decision": { + "APPROVED": "одобрен", + "REJECTED": "отклонён" + }, "pipeline_provider": { "GITHUB_ACTIONS": "GitHub Actions", "GITLAB_CI": "GitLab CI", @@ -5535,6 +5540,7 @@ "status": "Статус", "risk": "Риск ИИ", "outcome": "Результат", + "ticket": "Тикет", "created": "Отправлено", "filterAllStatuses": "Все статусы", "filterAllPipelines": "Все пайплайны", @@ -5566,6 +5572,10 @@ "aiSummary": "ИИ-анализ", "approvals": "Одобрения", "approvalsProgress": "{{granted}} из {{required}} одобрений", + "changeTicket": "Тикет изменения", + "changeTicketStatus": "Статус: {{status}}", + "changeTicketStatusResolution": "Статус: {{status}} ({{resolution}})", + "changeTicketNone": "Тикет изменения ещё не привязан. Релизы в этом окружении ждут одобренного тикета изменения ServiceNow или Jira.", "decisions": "Решения", "decision": "Решение", "reviewer": "Проверяющий", @@ -5577,6 +5587,8 @@ "bannerFrozen": "Удерживается окном заморозки — шлюз остаётся закрытым до конца окна.", "bannerFrozenReason": "Удерживается окном заморозки: {{reason}}", "bannerScheduled": "Запланировано — шлюз откроется в {{when}}.", + "bannerWaitingTicket": "Одобрено — ожидается тикет изменения. Шлюз откроется, когда привязанный тикет ServiceNow или Jira будет одобрен.", + "bannerTicketRejected": "Одобрено, но тикет изменения отклонён — шлюз остаётся закрытым.", "cancel": "Отменить развертывание", "cancelConfirmTitle": "Отменить это развертывание?", "cancelConfirmBody": "Шлюз пайплайна ответит «не готово к выпуску». Это действие необратимо.", @@ -5748,6 +5760,8 @@ "envRequiredApprovals": "Требуемые одобрения", "envReviewPlan": "План проверки", "envAllowBreakGlass": "Break-glass", + "envChangeTicketRequired": "Тикет изменения", + "envChangeTicketRequiredHelp": "Релизы в этом окружении ждут одобренного тикета изменения ServiceNow или Jira. Канал ServiceNow или Jira должен включать триггер развертывания, иначе шлюз никогда не откроется.", "envCreateTitle": "Добавить окружение", "envEditTitle": "Редактировать окружение", "envCreated": "Окружение создано", diff --git a/frontend/src/locales/zh-CN.json b/frontend/src/locales/zh-CN.json index b1f3d0dd8..c84b2d46b 100644 --- a/frontend/src/locales/zh-CN.json +++ b/frontend/src/locales/zh-CN.json @@ -2039,6 +2039,7 @@ "label_ticketing_trigger_query_rejected": "查询被拒绝", "label_ticketing_trigger_review_timeout": "审核超时", "label_ticketing_trigger_query_escalated": "路由策略升级", + "label_ticketing_trigger_deployment_submitted": "已提交部署(在需要变更工单的环境中开启变更工单)", "label_ticketing_bidirectional_sync": "双向状态同步", "help_ticketing_bidirectional_sync": "接受来自工单系统的已签名状态 Webhook;匹配的解决结果可以批准或拒绝待处理的查询。", "label_ticketing_webhook_secret": "Webhook 密钥", @@ -3756,6 +3757,10 @@ "ROLLED_BACK": "已回滚", "SUCCEEDED": "成功" }, + "deployment_ticket_decision": { + "APPROVED": "已批准", + "REJECTED": "已拒绝" + }, "pipeline_provider": { "GITHUB_ACTIONS": "GitHub Actions", "GITLAB_CI": "GitLab CI", @@ -5535,6 +5540,7 @@ "status": "状态", "risk": "AI 风险", "outcome": "结果", + "ticket": "工单", "created": "提交时间", "filterAllStatuses": "所有状态", "filterAllPipelines": "所有流水线", @@ -5566,6 +5572,10 @@ "aiSummary": "AI 分析", "approvals": "批准", "approvalsProgress": "{{granted}} / {{required}} 个批准", + "changeTicket": "变更工单", + "changeTicketStatus": "状态:{{status}}", + "changeTicketStatusResolution": "状态:{{status}}({{resolution}})", + "changeTicketNone": "尚未关联变更工单。此环境上的发布需等待已批准的 ServiceNow 或 Jira 变更工单。", "decisions": "决定", "decision": "决定", "reviewer": "审核人", @@ -5577,6 +5587,8 @@ "bannerFrozen": "被冻结窗口暂缓——窗口结束前闸门保持关闭。", "bannerFrozenReason": "被冻结窗口暂缓:{{reason}}", "bannerScheduled": "已计划——闸门将于 {{when}} 打开。", + "bannerWaitingTicket": "已批准 — 正在等待变更工单。关联的 ServiceNow 或 Jira 工单获批后闸门即开启。", + "bannerTicketRejected": "已批准,但变更工单被拒绝 — 闸门保持关闭。", "cancel": "取消部署", "cancelConfirmTitle": "取消此部署?", "cancelConfirmBody": "流水线闸门将返回不可发布。此操作无法撤销。", @@ -5748,6 +5760,8 @@ "envRequiredApprovals": "所需批准数", "envReviewPlan": "审核方案", "envAllowBreakGlass": "Break-glass", + "envChangeTicketRequired": "变更工单", + "envChangeTicketRequiredHelp": "此环境上的发布需等待已批准的 ServiceNow 或 Jira 变更工单。必须有 ServiceNow 或 Jira 渠道启用部署触发器,否则闸门永远不会开启。", "envCreateTitle": "添加环境", "envEditTitle": "编辑环境", "envCreated": "环境已创建", diff --git a/frontend/src/pages/admin/NotificationsPage.tsx b/frontend/src/pages/admin/NotificationsPage.tsx index 510141761..be9e34991 100644 --- a/frontend/src/pages/admin/NotificationsPage.tsx +++ b/frontend/src/pages/admin/NotificationsPage.tsx @@ -1004,6 +1004,10 @@ function ChannelFormModal({ value: 'QUERY_ESCALATED', label: t('admin.notifications.label_ticketing_trigger_query_escalated'), }, + { + value: 'DEPLOYMENT_SUBMITTED', + label: t('admin.notifications.label_ticketing_trigger_deployment_submitted'), + }, ]} /> diff --git a/frontend/src/pages/deployments/DeploymentDetailPage.test.tsx b/frontend/src/pages/deployments/DeploymentDetailPage.test.tsx index e15f7cd2f..16e122d3a 100644 --- a/frontend/src/pages/deployments/DeploymentDetailPage.test.tsx +++ b/frontend/src/pages/deployments/DeploymentDetailPage.test.tsx @@ -237,6 +237,97 @@ describe('DeploymentDetailPage', () => { expect(screen.queryByText(/Approved and releasable/)).toBeNull(); }); + it('shows the linked change ticket with its system, status and decision', async () => { + getDeploymentRequestMock.mockResolvedValue({ + ...baseRequest, + status: 'PENDING_REVIEW', + change_ticket_required: true, + change_ticket: { + system: 'SERVICENOW', + external_key: 'CHG0030001', + url: 'https://acme.service-now.com/change_request.do?sys_id=abc', + status: 'Scheduled', + resolution: 'Approved', + decision: 'APPROVED', + }, + }); + + render(wrap()); + + const link = await screen.findByRole('link', { name: 'CHG0030001' }); + expect(link).toHaveAttribute('href', 'https://acme.service-now.com/change_request.do?sys_id=abc'); + expect(link).toHaveAttribute('target', '_blank'); + expect(link).toHaveAttribute('rel', 'noopener noreferrer'); + expect(screen.getByText('ServiceNow')).toBeInTheDocument(); + expect(screen.getByText('Status: Scheduled (Approved)')).toBeInTheDocument(); + expect(screen.getByText('approved')).toBeInTheDocument(); + }); + + it('renders a ticket without a URL as plain text and without a decision tag', async () => { + getDeploymentRequestMock.mockResolvedValue({ + ...baseRequest, + status: 'PENDING_REVIEW', + change_ticket_required: true, + change_ticket: { system: 'JIRA', external_key: 'OPS-7', status: 'To Do' }, + }); + + render(wrap()); + + expect(await screen.findByText('OPS-7')).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'OPS-7' })).toBeNull(); + expect(screen.getByText('Status: To Do')).toBeInTheDocument(); + expect(screen.queryByText('approved')).toBeNull(); + }); + + it('says no change ticket is linked yet on a flagged environment', async () => { + getDeploymentRequestMock.mockResolvedValue({ ...baseRequest, change_ticket_required: true }); + getDeploymentGateMock.mockResolvedValue({ + ...releasableGate, + releasable: false, + change_ticket_required: true, + }); + + render(wrap()); + + expect(await screen.findByText(/No change ticket linked yet/)).toBeInTheDocument(); + expect(await screen.findByText(/waiting for the change ticket/)).toBeInTheDocument(); + }); + + it('hides the change-ticket card when the environment does not require one', async () => { + getDeploymentRequestMock.mockResolvedValue(baseRequest); + getDeploymentGateMock.mockResolvedValue(releasableGate); + + render(wrap()); + + await screen.findByText(/Approved and releasable/); + expect(screen.queryByText('Change ticket')).toBeNull(); + }); + + it('explains a rejected change ticket in the banner', async () => { + const rejected = { + system: 'JIRA' as const, + external_key: 'OPS-9', + status: 'Declined', + decision: 'REJECTED' as const, + }; + getDeploymentRequestMock.mockResolvedValue({ + ...baseRequest, + change_ticket_required: true, + change_ticket: rejected, + }); + getDeploymentGateMock.mockResolvedValue({ + ...releasableGate, + releasable: false, + change_ticket_required: true, + change_ticket: rejected, + }); + + render(wrap()); + + expect(await screen.findByText(/the change ticket was rejected/)).toBeInTheDocument(); + expect(screen.getByText('rejected')).toBeInTheDocument(); + }); + it("hides the cancel button on someone else's request", async () => { // Cancellable by state (deferred run still ahead) — only ownership withholds the button. getDeploymentRequestMock.mockResolvedValue({ diff --git a/frontend/src/pages/deployments/DeploymentDetailPage.tsx b/frontend/src/pages/deployments/DeploymentDetailPage.tsx index a43059eca..fc8beaa08 100644 --- a/frontend/src/pages/deployments/DeploymentDetailPage.tsx +++ b/frontend/src/pages/deployments/DeploymentDetailPage.tsx @@ -40,8 +40,10 @@ import { rejectDeployment, } from '@/api/deploymentReviews'; import { DriftChip } from '@/components/deployments/versionMatrixCells'; +import { ChangeTicketLink } from '@/components/deployments/ChangeTicketLink'; import { useAuthStore } from '@/store/authStore'; import { + channelTypeLabel, pipelineProviderLabel, reviewDecisionTypeLabel, submissionReasonLabel, @@ -232,7 +234,14 @@ export default function DeploymentDetailPage() { {request.status === 'APPROVED' && gate && ( + {(request.change_ticket_required || request.change_ticket) && ( + +
+ {request.change_ticket ? ( + <> +
+ + + {channelTypeLabel(t, request.change_ticket.system)} + +
+
+ {request.change_ticket.resolution + ? t('deploygov.detail.changeTicketStatusResolution', { + status: request.change_ticket.status, + resolution: request.change_ticket.resolution, + }) + : t('deploygov.detail.changeTicketStatus', { + status: request.change_ticket.status, + })} +
+ + ) : ( + {t('deploygov.detail.changeTicketNone')} + )} +
+
+ )} + {metadataEntries.length > 0 && (
diff --git a/frontend/src/pages/deployments/DeploymentListPage.test.tsx b/frontend/src/pages/deployments/DeploymentListPage.test.tsx index 6f4c51ad2..e7b17538e 100644 --- a/frontend/src/pages/deployments/DeploymentListPage.test.tsx +++ b/frontend/src/pages/deployments/DeploymentListPage.test.tsx @@ -171,6 +171,32 @@ describe('DeploymentListPage', () => { expect(screen.getByText('1 deployments')).toBeInTheDocument(); }); + it('shows the linked change ticket with its decision in the ticket column', async () => { + listDeploymentRequestsMock.mockResolvedValue( + pageOf([ + { + ...baseRequest, + change_ticket_required: true, + change_ticket: { + system: 'JIRA', + external_key: 'OPS-42', + url: 'https://jira.example.com/browse/OPS-42', + status: 'Done', + decision: 'APPROVED', + }, + }, + ]), + ); + + render(wrap()); + + const link = await screen.findByText('OPS-42'); + expect(link.closest('a')).toHaveAttribute('href', 'https://jira.example.com/browse/OPS-42'); + expect(link.closest('a')).toHaveAttribute('rel', 'noopener noreferrer'); + expect(screen.getByText('approved')).toBeInTheDocument(); + expect(screen.getAllByText('Ticket').length).toBeGreaterThan(0); + }); + it('does not ask for pipelines without DEPLOYMENT_PIPELINE_MANAGE and still fills the filter from rows', async () => { listDeploymentRequestsMock.mockResolvedValue(pageOf([baseRequest])); diff --git a/frontend/src/pages/deployments/DeploymentListPage.tsx b/frontend/src/pages/deployments/DeploymentListPage.tsx index 059cbaa74..2632f516f 100644 --- a/frontend/src/pages/deployments/DeploymentListPage.tsx +++ b/frontend/src/pages/deployments/DeploymentListPage.tsx @@ -15,6 +15,7 @@ import { listPipelineEnvironmentVersions, } from '@/api/deploymentVersions'; import { DriftChip } from '@/components/deployments/versionMatrixCells'; +import { ChangeTicketLink } from '@/components/deployments/ChangeTicketLink'; import { deploymentOutcomeLabel, enumOptions, queryStatusLabel } from '@/utils/enumLabels'; import { deploymentOutcomeColor } from '@/utils/statusColors'; import { timeAgo } from '@/utils/dateFormat'; @@ -154,6 +155,17 @@ export default function DeploymentListPage() { ); }, }, + { + title: t('deploygov.deployments.ticket'), + key: 'change_ticket', + width: 160, + render: (_v, r) => + r.change_ticket ? ( + + ) : ( + — + ), + }, { title: t('deploygov.versions.drift'), key: 'drift', diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 52f28bdde..07dc397ff 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -5066,6 +5066,8 @@ export interface DeploymentEnvironment { required_approvals: number | null; review_plan_id: string | null; allow_break_glass: boolean; + /** Releases wait for an approved ServiceNow / Jira change ticket (#990, #993). */ + change_ticket_required: boolean; /** The datasource this environment's schema changes land on; null = deploy-only (#877). */ datasource_id: string | null; created_at: string; @@ -5080,6 +5082,7 @@ export interface CreateDeploymentEnvironmentInput { required_approvals?: number | null; review_plan_id?: string | null; allow_break_glass?: boolean; + change_ticket_required?: boolean; datasource_id?: string | null; } @@ -5097,6 +5100,7 @@ export interface UpdateDeploymentEnvironmentInput { review_plan_id?: string | null; clear_review_plan?: boolean; allow_break_glass?: boolean | null; + change_ticket_required?: boolean | null; /** Null leaves the binding unchanged; `clear_datasource: true` unbinds and wins (#877). */ datasource_id?: string | null; clear_datasource?: boolean; @@ -5288,10 +5292,29 @@ export interface DeploymentRequest { * endpoint only — always false on the list endpoint. */ can_review: boolean; + /** Mirrors the environment's flag (#994). */ + change_ticket_required?: boolean; + /** Absent when no ticket is linked (#994). */ + change_ticket?: DeploymentTicketRef | null; } export type DeploymentRequestPage = PageEnvelope; +export type DeploymentTicketDecision = 'APPROVED' | 'REJECTED'; + +/** + * The linked change ticket that explains a deployment's state (#993, #994): the newest rejected + * one, else the newest approved one, else the newest linked one. + */ +export interface DeploymentTicketRef { + system: 'SERVICENOW' | 'JIRA'; + external_key: string; + url?: string | null; + status: string; + resolution?: string | null; + decision?: DeploymentTicketDecision | null; +} + export interface DeploymentRequestListFilters { status?: QueryStatus; pipeline_id?: string; @@ -5364,6 +5387,10 @@ export interface DeploymentGateStatus { freeze_reason: string | null; scheduled_for: string | null; ai_risk_level: RiskLevel | null; + /** Mirrors the environment's flag (#993). */ + change_ticket_required?: boolean; + /** The ticket the gate judged by; absent when none is linked (#993). */ + change_ticket?: DeploymentTicketRef | null; } // ─── Deployment version inventory & drift (#742 API, #743 UI) ─── diff --git a/frontend/src/utils/enumLabels.test.ts b/frontend/src/utils/enumLabels.test.ts index 5f5893cd3..71b1a5267 100644 --- a/frontend/src/utils/enumLabels.test.ts +++ b/frontend/src/utils/enumLabels.test.ts @@ -4,10 +4,12 @@ import { AUDIT_SINK_TYPES, DEPLOYMENT_OUTCOMES, DEPLOYMENT_ROLLBACK_REVIEW_STATUSES, + DEPLOYMENT_TICKET_DECISIONS, FREEZE_BEHAVIORS, PIPELINE_PROVIDERS, deploymentOutcomeLabel, deploymentRollbackReviewStatusLabel, + deploymentTicketDecisionLabel, freezeBehaviorLabel, isoWeekdayLabel, pipelineProviderLabel, @@ -257,6 +259,7 @@ describe('deployment governance labels (#696)', () => { expect(FREEZE_BEHAVIORS).toEqual(['HOLD', 'REJECT']); expect(DEPLOYMENT_OUTCOMES).toEqual(['SUCCEEDED', 'FAILED', 'ROLLED_BACK']); expect(DEPLOYMENT_ROLLBACK_REVIEW_STATUSES).toEqual(['PENDING_REVIEW', 'REVIEWED']); + expect(DEPLOYMENT_TICKET_DECISIONS).toEqual(['APPROVED', 'REJECTED']); }); it('resolves every label against the real bundle', async () => { @@ -274,6 +277,9 @@ describe('deployment governance labels (#696)', () => { for (const status of DEPLOYMENT_ROLLBACK_REVIEW_STATUSES) { expect(deploymentRollbackReviewStatusLabel(realT, status)).not.toContain('enums.'); } + for (const decision of DEPLOYMENT_TICKET_DECISIONS) { + expect(deploymentTicketDecisionLabel(realT, decision)).not.toContain('enums.'); + } }); it('maps ISO weekday numbers onto weekday labels, tolerating out-of-range input', async () => { diff --git a/frontend/src/utils/enumLabels.ts b/frontend/src/utils/enumLabels.ts index c16c16c23..15e7f77a0 100644 --- a/frontend/src/utils/enumLabels.ts +++ b/frontend/src/utils/enumLabels.ts @@ -77,6 +77,7 @@ import type { RowSecurityValueType, DiscoveryDetector, DeploymentOutcome, + DeploymentTicketDecision, DeploymentRollbackReviewStatus, DiscoveryFindingStatus, CommentStatus, @@ -966,6 +967,14 @@ export const DEPLOYMENT_OUTCOMES: readonly DeploymentOutcome[] = [ export const deploymentOutcomeLabel = (t: TFunction, v: DeploymentOutcome): string => t(`enums.deployment_outcome.${v}` as const); +export const DEPLOYMENT_TICKET_DECISIONS: readonly DeploymentTicketDecision[] = [ + 'APPROVED', + 'REJECTED', +] as const; + +export const deploymentTicketDecisionLabel = (t: TFunction, v: DeploymentTicketDecision): string => + t(`enums.deployment_ticket_decision.${v}` as const); + export const DEPLOYMENT_ROLLBACK_REVIEW_STATUSES: readonly DeploymentRollbackReviewStatus[] = [ 'PENDING_REVIEW', 'REVIEWED', diff --git a/frontend/src/utils/statusColors.test.ts b/frontend/src/utils/statusColors.test.ts index cbc834667..ec373fd3e 100644 --- a/frontend/src/utils/statusColors.test.ts +++ b/frontend/src/utils/statusColors.test.ts @@ -9,6 +9,7 @@ import { deploymentOutcomeColor, driftColor, deploymentRollbackReviewStatusColor, + deploymentTicketDecisionColor, grantUsageRecommendationColor, jobExecutionStatusColor, standingBypassKindColor, @@ -148,6 +149,13 @@ describe('deploymentOutcomeColor (#696)', () => { }); }); +describe('deploymentTicketDecisionColor (#994)', () => { + it('maps an approved ticket to low risk and a rejected one to critical', () => { + expect(deploymentTicketDecisionColor('APPROVED').fg).toBe('var(--risk-low)'); + expect(deploymentTicketDecisionColor('REJECTED').fg).toBe('var(--risk-crit)'); + }); +}); + describe('deploymentRollbackReviewStatusColor (#696)', () => { it('flags pending rollback reviews and settles reviewed ones', () => { expect(deploymentRollbackReviewStatusColor('PENDING_REVIEW').fg).toBe('var(--risk-crit)'); diff --git a/frontend/src/utils/statusColors.ts b/frontend/src/utils/statusColors.ts index e3c4de509..3fa156223 100644 --- a/frontend/src/utils/statusColors.ts +++ b/frontend/src/utils/statusColors.ts @@ -7,6 +7,7 @@ import type { DecisionStepOutcome, DeploymentOutcome, DeploymentRollbackReviewStatus, + DeploymentTicketDecision, GrantUsageRecommendation, JobExecutionStatus, QueryStatus, @@ -222,6 +223,15 @@ export const deploymentOutcomeColor = (outcome: DeploymentOutcome): ColorTriple } }; +export const deploymentTicketDecisionColor = (decision: DeploymentTicketDecision): ColorTriple => { + switch (decision) { + case 'APPROVED': + return { fg: 'var(--risk-low)', bg: 'var(--risk-low-bg)', border: 'var(--risk-low-border)' }; + case 'REJECTED': + return { fg: 'var(--risk-crit)', bg: 'var(--risk-crit-bg)', border: 'var(--risk-crit-border)' }; + } +}; + export const deploymentRollbackReviewStatusColor = ( status: DeploymentRollbackReviewStatus, ): ColorTriple => { diff --git a/help-corpus/corpus.jsonl b/help-corpus/corpus.jsonl index b696783bb..a58453a4f 100644 --- a/help-corpus/corpus.jsonl +++ b/help-corpus/corpus.jsonl @@ -220,8 +220,8 @@ {"id":"0969659acece6b75","path":"website/docs/configuration/datasources/index.html","url":"https://accessflow.io/docs/configuration/datasources/#cfg-datasource-health","anchor":"cfg-datasource-health","title":"Datasource health","section":"Reference","order":0,"tokens":282,"text":"AccessFlow Docs > Reference > Datasources > Datasource health\n\nWhat it is. An operational dashboard for spotting a datasource in trouble\n— pool exhaustion, a database that has turned slow, or a sudden volume spike. Use it as the\nfirst place to look when queries start failing or hanging.\n\nConfigure it. Nothing to set up. /admin/datasource-health\n(admin-only, read-only) auto-refreshes every 30 seconds; each card shows live\nconnection-pool utilisation (active / idle / free against the configured maximum) plus a\ntrailing 24-hour summary of query volume, p50 / p95 execution latency, and error count.\nPool gauges read \"pool not initialised\" until the datasource's first query runs (pools are\ncreated on demand).\n\nTune it. ACCESSFLOW_PROXY_HEALTH_CACHE_TTL caches each\nsnapshot so the auto-refresh stays cheap (default PT30S).\n\n/admin/datasource-health — one card per datasource, auto-refreshing every 30 s."} {"id":"67af1cbac99c74cf","path":"website/docs/configuration/notifications/index.html","url":"https://accessflow.io/docs/configuration/notifications/#cfg-notification-channels","anchor":"cfg-notification-channels","title":"Notification channels","section":"Reference","order":0,"tokens":163,"text":"AccessFlow Docs > Reference > Notifications > Notification channels\n\nThere is a guide for this. Set up email and chat notifications\nstarts with system SMTP — the piece that makes email invitations work at all. This chapter is the reference behind it.\n\nWhat it is. Where AccessFlow sends review notifications — when a query is\nsubmitted, approved, rejected, times out, or scores CRITICAL on AI risk. Wire\nup the channels your team already lives in so reviewers act fast. Configure as many as you\nlike per organization (e.g. one Slack channel per team)."} {"id":"87cf866ab168e52d","path":"website/docs/configuration/notifications/index.html","url":"https://accessflow.io/docs/configuration/notifications/","anchor":"","title":"Where can AccessFlow send review notifications?","section":"Reference","order":0,"tokens":722,"text":"AccessFlow Docs > Reference > Notifications > Where can AccessFlow send review notifications? (part 1 of 3)\n\nEmail, Slack, Discord, Telegram, Microsoft Teams, PagerDuty, ServiceNow, Jira, and signed HTTP webhooks. Configure as many channels as you need per organization. Organization-wide events — high AI risk, anomalies, break-glass, the weekly digest, deployments, schema changes and schema drift — reach every active channel; the ordinary query lifecycle follows the channels bound to the datasource's review plan. PagerDuty, ServiceNow and Jira additionally carry their own trigger list. There is no general per-channel event picker.\n\nConfigure it. Manage channels at /admin/notifications:\n\n/admin/notifications → Add channel. The form swaps its lower half between Email, Slack, Webhook, Discord, Telegram, Microsoft Teams, PagerDuty, ServiceNow, and Jira fields when you change Type.\n\n- Open /admin/notifications and click Add channel.\n\n- Pick a type:\n\n- Email — supply SMTP host, port, password, TLS, the From address and an optional user / display name. The user is optional; the password is not. An email channel is a full, independent SMTP configuration — it does not inherit system SMTP, and leaving its fields blank does not fall back to it. The system-SMTP fallback is decided per event, over the channels resolved for that event — not over the organization's channel list — so adding an email channel here does not on its own switch it off.\n\n- Slack — paste an incoming-webhook URL for one-way Block Kit messages (header, SQL preview, and a View in AccessFlow link). For interactive Approve / Reject buttons, configure a Slack app instead (see below).\n\n- Webhook — supply a target URL and a signing secret. Every POST carries X-AccessFlow-Signature: sha256=... computed from the body and the secret.\n\n- Discord — paste a channel webhook URL (Server Settings → Integrations → Webhooks). Optional username and avatar URL override the bot identity per-message. Messages render as a rich embed with the SQL preview in a fenced code block.\n\n- Telegram — create a bot via @BotFather, add it to the target group/channel, and configure the bot token plus numeric chat ID. Messages use MarkdownV2 formatting.\n\n- Microsoft Teams — paste the channel's Incoming Webhook (or Power Automate webhook) URL. Messages render as an Adaptive Card with a View in AccessFlow action button."} -{"id":"c6673b948b5d00e7","path":"website/docs/configuration/notifications/index.html","url":"https://accessflow.io/docs/configuration/notifications/","anchor":"","title":"Where can AccessFlow send review notifications?","section":"Reference","order":1,"tokens":790,"text":"AccessFlow Docs > Reference > Notifications > Where can AccessFlow send review notifications? (part 2 of 3)\n\n- PagerDuty — paste an Events API v2 integration routing key, pick a default severity (critical / error / warning / info), and choose which triggers page the channel: AI critical-risk query, review timeout, routing-policy escalation, and/or review stalled (nobody decided within the plan’s escalation window). Other events never fire it — a nudge reminder in particular never pages. AccessFlow sends a trigger event with a query-stable dedup key so repeat events fold into one incident.\n\n- ServiceNow — supply the instance URL plus a username / password allowed to create incidents via the Table API, optionally an assignment group and urgency (1–3), and choose which triggers open an incident: query rejected, review timeout, and/or routing-policy escalation. Created incidents link back onto the query detail page.\n\n- Jira — supply the site URL, an account email + API token, the target project key, and optionally an issue type (default Task); pick the same trigger set. Issues are created via REST v2 with an accessflow label and link back onto the query detail page.\n\n- Bi-directional status sync (ServiceNow / Jira). Toggle Bi-directional status sync on the channel and set a webhook secret; then point a ServiceNow Business Rule / Jira Automation rule at POST /api/v1/integrations/servicenow/webhook/{channelId} (or …/jira/webhook/{channelId}) sending {\"external_id\", \"status\", \"resolution\", \"actor\"} signed with X-AccessFlow-Timestamp + X-AccessFlow-Signature: sha256=HMAC-SHA256(secret, \"v1:{timestamp}:{body}\"). Ticket status updates flow onto the query detail page, and a resolution matching the channel's approve / reject status lists decides a query still awaiting review — attributed to the ticketing system in the audit log. Deployment change tickets are matched too: AccessFlow always keeps the approve / reject verdict on its own link to the ticket, and with sync on it also decides the deployment awaiting review. The ticket's actor cannot be matched to an AccessFlow user, so self-approval of a change ticket is not blocked — keep sync off if you need that guarantee.\n\n- Send a test event. After saving, each channel card has a Test button — the dispatcher fires a synthetic event and reports delivery success.\n\n- Edit or remove. Each card also has Edit and Delete buttons. Deleting a channel is immediate (hard delete) — there is no soft-delete, but webhook retries already scheduled before the delete still complete."} -{"id":"999c2b15b8129e9e","path":"website/docs/configuration/notifications/index.html","url":"https://accessflow.io/docs/configuration/notifications/","anchor":"","title":"Where can AccessFlow send review notifications?","section":"Reference","order":2,"tokens":358,"text":"AccessFlow Docs > Reference > Notifications > Where can AccessFlow send review notifications? (part 3 of 3)\n\nSensitive config fields (SMTP password, webhook secret,\nTelegram bot token, PagerDuty routing key, ServiceNow password,\nJira API token, ticketing webhook secret) are AES-256-GCM encrypted at\nrest and are never shown in plaintext after the channel is saved.\n\nWebhook retry policy. One immediate attempt plus three scheduled\nretries at +30s, +2 min, +10 min. Tune the\ndelays via ACCESSFLOW_NOTIFICATIONS_RETRY_FIRST /\n_SECOND / _THIRD. Failed deliveries log ERROR\nbut never affect query workflow state.\n\nTune it. ACCESSFLOW_PUBLIC_BASE_URL sets the link embedded in\nmessages (default http://localhost:5173); retry delays are\nACCESSFLOW_NOTIFICATIONS_RETRY_FIRST / _SECOND / _THIRD\n(PT30S / PT2M / PT10M). For air-gapped installs that\nroute through an internal proxy, override\nACCESSFLOW_NOTIFICATIONS_TELEGRAM_API_BASE_URL\n(https://api.telegram.org/) and\nACCESSFLOW_NOTIFICATIONS_PAGERDUTY_API_BASE_URL\n(https://events.pagerduty.com/). The inbound ticketing-webhook signature\nwindow is ACCESSFLOW_NOTIFICATIONS_TICKETING_SIGNATURE_TOLERANCE\n(PT5M)."} +{"id":"c6673b948b5d00e7","path":"website/docs/configuration/notifications/index.html","url":"https://accessflow.io/docs/configuration/notifications/","anchor":"","title":"Where can AccessFlow send review notifications?","section":"Reference","order":1,"tokens":756,"text":"AccessFlow Docs > Reference > Notifications > Where can AccessFlow send review notifications? (part 2 of 3)\n\n- PagerDuty — paste an Events API v2 integration routing key, pick a default severity (critical / error / warning / info), and choose which triggers page the channel: AI critical-risk query, review timeout, routing-policy escalation, and/or review stalled (nobody decided within the plan’s escalation window). Other events never fire it — a nudge reminder in particular never pages. AccessFlow sends a trigger event with a query-stable dedup key so repeat events fold into one incident.\n\n- ServiceNow — supply the instance URL plus a username / password allowed to create incidents via the Table API, optionally an assignment group and urgency (1–3), and choose which triggers open an incident: query rejected, review timeout, routing-policy escalation, and/or deployment submitted. Incidents for queries link back onto the query detail page. The deployment submitted trigger opens an incident for each deployment to an environment that requires a change ticket. That incident is the deployment’s change ticket, and it shows on the deployment’s page.\n\n- Jira — supply the site URL, an account email + API token, the target project key, and optionally an issue type (default Task); pick the same trigger set. Issues are created via REST v2 with an accessflow label and link back onto the query detail page.\n\n- Bi-directional status sync (ServiceNow / Jira). Toggle Bi-directional status sync on the channel and set a webhook secret; then point a ServiceNow Business Rule / Jira Automation rule at POST /api/v1/integrations/servicenow/webhook/{channelId} (or …/jira/webhook/{channelId}) sending {\"external_id\", \"status\", \"resolution\", \"actor\"} signed with X-AccessFlow-Timestamp + X-AccessFlow-Signature: sha256=HMAC-SHA256(secret, \"v1:{timestamp}:{body}\"). Ticket status updates flow onto the query detail page, and a resolution matching the channel's approve / reject status lists decides a query still awaiting review — attributed to the ticketing system in the audit log. Deployment change tickets are matched too: AccessFlow always keeps the approve / reject verdict on its own link to the ticket, and with sync on it also decides the deployment awaiting review. The ticket's actor cannot be matched to an AccessFlow user, so self-approval of a change ticket is not blocked — keep sync off if you need that guarantee."} +{"id":"999c2b15b8129e9e","path":"website/docs/configuration/notifications/index.html","url":"https://accessflow.io/docs/configuration/notifications/","anchor":"","title":"Where can AccessFlow send review notifications?","section":"Reference","order":2,"tokens":461,"text":"AccessFlow Docs > Reference > Notifications > Where can AccessFlow send review notifications? (part 3 of 3)\n\n- Send a test event. After saving, each channel card has a Test button — the dispatcher fires a synthetic event and reports delivery success.\n\n- Edit or remove. Each card also has Edit and Delete buttons. Deleting a channel is immediate (hard delete) — there is no soft-delete, but webhook retries already scheduled before the delete still complete.\n\nSensitive config fields (SMTP password, webhook secret,\nTelegram bot token, PagerDuty routing key, ServiceNow password,\nJira API token, ticketing webhook secret) are AES-256-GCM encrypted at\nrest and are never shown in plaintext after the channel is saved.\n\nWebhook retry policy. One immediate attempt plus three scheduled\nretries at +30s, +2 min, +10 min. Tune the\ndelays via ACCESSFLOW_NOTIFICATIONS_RETRY_FIRST /\n_SECOND / _THIRD. Failed deliveries log ERROR\nbut never affect query workflow state.\n\nTune it. ACCESSFLOW_PUBLIC_BASE_URL sets the link embedded in\nmessages (default http://localhost:5173); retry delays are\nACCESSFLOW_NOTIFICATIONS_RETRY_FIRST / _SECOND / _THIRD\n(PT30S / PT2M / PT10M). For air-gapped installs that\nroute through an internal proxy, override\nACCESSFLOW_NOTIFICATIONS_TELEGRAM_API_BASE_URL\n(https://api.telegram.org/) and\nACCESSFLOW_NOTIFICATIONS_PAGERDUTY_API_BASE_URL\n(https://events.pagerduty.com/). The inbound ticketing-webhook signature\nwindow is ACCESSFLOW_NOTIFICATIONS_TICKETING_SIGNATURE_TOLERANCE\n(PT5M)."} {"id":"ab62165923a4c6c5","path":"website/docs/configuration/notifications/index.html","url":"https://accessflow.io/docs/configuration/notifications/#cfg-slack","anchor":"cfg-slack","title":"Slack app — Approve / Reject from Slack","section":"Reference","order":0,"tokens":444,"text":"AccessFlow Docs > Reference > Notifications > Slack app — Approve / Reject from Slack\n\nBeyond the one-way Slack webhook channel, an admin can connect a Slack app\nat /admin/slack so reviewers act on requests without leaving Slack. Create the app\nin Slack's console, then paste its App ID, Bot token, Signing secret,\nand a default channel ID. When an app is active, review-request messages are posted via\nthe bot token and carry Approve and Reject buttons.\n\n- Each reviewer links their Slack identity once. On /profile →\nSlack account, click Generate link code, then run\n/accessflow link in Slack. The code is single-use and short-lived\n(ACCESSFLOW_NOTIFICATIONS_SLACK_LINK_CODE_TTL, default 10 min).\n\n- Clicking Approve / Reject runs through the same review service as the UI —\na reviewer can never approve their own query, and role / stage checks apply identically.\nThe original Slack message updates in place with the decision.\n\nInbound clicks are verified by the Slack signing secret (HMAC over the raw body, with a\ntimestamp/replay window set by ACCESSFLOW_NOTIFICATIONS_SLACK_SIGNATURE_TOLERANCE,\ndefault 5 min). The bot token and signing secret are AES-256-GCM encrypted at rest and never\nreturned by the API. Point your Slack app's Interactivity request URL at\n/api/v1/integrations/slack/actions and its slash command at\n/api/v1/integrations/slack/commands.\n\n/admin/slack — connect the interactive Slack app (bot token + signing secret)."} {"id":"32f3a034a58a3dba","path":"website/docs/configuration/notifications/index.html","url":"https://accessflow.io/docs/configuration/notifications/#cfg-smtp","anchor":"cfg-smtp","title":"System SMTP","section":"Reference","order":0,"tokens":336,"text":"AccessFlow Docs > Reference > Notifications > System SMTP\n\nWhat it is. The fallback mail server for transactional email — password\nresets, user invitations, and any email notification channel that doesn't carry its own\nSMTP settings. Set it once per org so those emails can go out.\n\nConfigure it. On /admin/notifications, the first card is\nSystem SMTP:\n\n/admin/notifications → System SMTP card → Configure.\n\n- Open /admin/notifications. The first card is System SMTP — click Configure (or Edit if it's already set).\n\n- Fill the form. Host, port (587 for STARTTLS, 465 for implicit TLS), optional username and password, STARTTLS toggle, and the From address — required and validated as an email. The password field shows \"Leave blank to keep the existing password\" when editing, so secrets aren't echoed back.\n\n- Send a test email. After saving, the card exposes a sink-address input + Send test button so you can verify deliverability without firing a real workflow event.\n\nThe password is AES-256-GCM encrypted at rest. Delete the row from the same card if\nyou'd rather rely solely on per-channel email overrides."} {"id":"ab3787a9c974540a","path":"website/docs/configuration/review-workflows/index.html","url":"https://accessflow.io/docs/configuration/review-workflows/#cfg-review-plans","anchor":"cfg-review-plans","title":"Review plans","section":"Reference","order":0,"tokens":737,"text":"AccessFlow Docs > Reference > Review workflows > Review plans\n\nThere is a guide for this. Run your first governed query\nwalks through building a review plan and attaching it, and\nGate a CI/CD pipeline applies the same machinery to\ndeployments — including the approver scoping that is easy to miss. This chapter is the reference behind both.\n\nWhat it is. The approval policy attached to a datasource — it decides how\na submitted query gets from \"submitted\" to \"executed\". Use it to require AI scoring, one or\nmore human sign-offs, or both, and to auto-approve low-risk reads. Each plan is a sequence\nof stages; each stage names approvers (by role or specific user) and a minimum number of\napprovals before the query advances.\n\nConfigure it. Build plans at /admin/review-plans — start\nfrom a built-in template or from scratch. A rule that names a role covers every holder of\nthat role, service accounts included. When a rule's role is held by active service\naccounts, the editor warns under that rule and shows how many could approve. The warning\nis advisory: it does not stop you saving the plan.\n\n/admin/review-plans → caret next to Add review plan exposes four built-in templates that prefill the create modal.\n\n/admin/review-plans → Add review plan. Stack approver rows to build multi-stage chains.\n\n- Open /admin/review-plans and click Add review plan, or pick a built-in template from the dropdown caret next to it (Strict — writes need 2 approvals, Lenient — reads auto-approved, AI-only — no human approval, Standard — AI + 1 reviewer) to prefill the modal with sensible defaults.\n\n- Name and describe the policy — e.g. \"Production writes — two reviewers\".\n\n- Pick the gates. Toggle Require AI review to score every query before it queues for humans; toggle Require human approval to demand at least one reviewer sign-off. Auto-approve LOW-risk reads lets SELECTs skip humans entirely when AI risk is below the threshold.\n\n- Set thresholds. Minimum approvals is the number of distinct reviewers needed before the query advances; Approval timeout (hours) is when AccessFlow auto-rejects an idle PENDING_REVIEW query (it scans on a cadence set by ACCESSFLOW_WORKFLOW_TIMEOUT_POLL_INTERVAL, default PT5M).\n\n- Build the approver chain. Click Add approver for each stage; each row names a role (or a specific user) and a stage number. Stages advance sequentially, and a single REJECTED decision at any stage terminates the query."} @@ -295,12 +295,12 @@ {"id":"798f9e2db8350c58","path":"website/docs/guides/deployment-approval/index.html","url":"https://accessflow.io/docs/guides/deployment-approval/#guide-deployment-account","anchor":"guide-deployment-account","title":"1. Create the CI service account","section":"Guides","order":0,"tokens":371,"text":"AccessFlow Docs > Guides > Gate a deployment pipeline > 1. Create the CI service account\n\nThe API key's owner is the submitter of every deployment it opens, and\nnobody can approve their own deployment. So the CI identity must be an account that\nnever approves — not your admin account. AccessFlow has a dedicated kind of account for\nthis: a service account, which has no password, can never sign in, and\nonly ever authenticates with an API key.\n\n- Open the service accounts page. Sidebar → Security &\nAccess → Identity → Service accounts.\n\n- Create it. Click Create service account and fill\nEmail (an identifier only — nothing is sent to it) and\nDisplay name.\n\n- Keep the role narrow. Leave Role on\nRead-only — the right to trigger deployments comes from the\nper-pipeline grant in step 5, not from the role.\n\n- Optionally name an owner. The person accountable for this pipeline,\nso a reviewer knows who to ask.\n\nManaging everything as code? Steps 1 and 2 can be declared instead,\nwith bootstrap.serviceAccounts[] — it creates the same kind of account and\nimports a key you generated yourself. Always set its role: when\nomitted it defaults to Admin. A later change is applied on the next restart. See Automate\nwith Terraform, or service-account API\nkeys for the full property list."} {"id":"d30a17c09c26c45a","path":"website/docs/guides/deployment-approval/index.html","url":"https://accessflow.io/docs/guides/deployment-approval/#guide-deployment-key","anchor":"guide-deployment-key","title":"2. Issue its API key","section":"Guides","order":0,"tokens":270,"text":"AccessFlow Docs > Guides > Gate a deployment pipeline > 2. Issue its API key\n\nCreating the account opens its settings page. You issue the key from there, as the\nadmin — nobody signs in as the service account.\n\n- Open its keys. The API keys tab →\nIssue key.\n\n- Name it and copy the value. Call it after the consumer, for example\ngithub-actions. The raw af_… key is shown once and never\nagain. Paste it straight into your CI secret store.\n\nThink twice before giving a pipeline key an expiry. The form's\noptional Expires field takes a date and time — the picker greys out anything in\nthe past — but the gate is fail-closed, so on the day it passes every deployment job\nstops until someone issues a new key. Leave it empty and the key lasts until you revoke\nit. To change it on a schedule, use Rotate instead: the old key keeps\nworking for a grace period (24 hours by default) while you swap the CI secret."} {"id":"ea71c3bac43f7d75","path":"website/docs/guides/deployment-approval/index.html","url":"https://accessflow.io/docs/guides/deployment-approval/#guide-deployment-pipeline","anchor":"guide-deployment-pipeline","title":"3. Create the pipeline","section":"Guides","order":0,"tokens":284,"text":"AccessFlow Docs > Guides > Gate a deployment pipeline > 3. Create the pipeline\n\nBack on your admin account: sidebar → Connections → Deployments →\nDeployment Pipelines → Add pipeline. Give it a name and pick the\nprovider that matches your CI system — GitHub Actions, GitLab CI, Azure Pipelines,\nJenkins, CircleCI, Bitbucket Pipelines, or generic.\n\nCopy the pipeline id now. Creating a pipeline lands you on its settings\npage, where the UUID sits under the pipeline name with a copy button beside it — that\nUUID is the pipeline id your CI job needs. The pipeline list carries the\nsame id in an ID column, shortened for the table but copying the whole UUID, so\nyou never have to open a pipeline or read the address bar just to grab it.\n\nThe pipeline is identified by that UUID rather than by name because a trigger-only API\nkey is not allowed to list pipelines, so it has nothing to resolve a name against.\n\n/admin/deployment-pipelines — one row per pipeline. Open one to reach its seven settings tabs."} -{"id":"b86bd289b80f5786","path":"website/docs/guides/deployment-approval/index.html","url":"https://accessflow.io/docs/guides/deployment-approval/#guide-deployment-environments","anchor":"guide-deployment-environments","title":"4. Add environments","section":"Guides","order":0,"tokens":617,"text":"AccessFlow Docs > Guides > Gate a deployment pipeline > 4. Add environments\n\nAn environment is what a deploy targets, and it is where you decide whether a release\nneeds a human at all. On the pipeline's Environments tab, choose\nAdd environment. Name it exactly as your CI job will name it —\nstaging, production — turn Requires review\non, and set Required approvals.\n\nTwo rules decide how many approvals a release actually needs, and which people count:\n\n- An environment's Required approvals replaces the review\nplan's minimum when it is set. It does not add to it.\n\n- An environment's Review plan override beats the one attached to the\npipeline. Leave it on Plan default to inherit.\n\nWhile you are here, fill in Tags — short words of your choosing, at\nmost ten. They have no meaning to AccessFlow; they exist so you can group environments\nacross pipelines and read them back together. If you run one copy of your application\nper customer, the usual shape is one environment named prod-acme tagged\nacme and prod, so you can later ask for everything belonging\nto that customer, or everything in production, in one view.\n\nTwo more fields shape the environment's place in the pipeline. Order\nis its rung on the promotion ladder — dev before staging\nbefore production. It is filled in for you (one past the last\nenvironment) and no two environments on a pipeline may share a value; if you type one\nthat is taken, AccessFlow tells you and keeps the old order. Database\nis optional: pick the datasource that backs this environment when you want AccessFlow\nto know which database an environment stands for. Leave it empty for an environment\nthat only receives deployments — nothing about the approval or the gate changes either\nway.\n\nEnvironments are also where break-glass is switched on. An emergency\ndeploy needs both a break-glass grant on the pipeline and this opt-in on the target\nenvironment; there is no admin bypass for either half. Leave it off unless you have\nsomebody who genuinely needs it.\n\nPipeline settings → Environments — promotion order, tags, required approvals, review-plan override and break-glass, per environment."} +{"id":"b86bd289b80f5786","path":"website/docs/guides/deployment-approval/index.html","url":"https://accessflow.io/docs/guides/deployment-approval/#guide-deployment-environments","anchor":"guide-deployment-environments","title":"4. Add environments","section":"Guides","order":0,"tokens":705,"text":"AccessFlow Docs > Guides > Gate a deployment pipeline > 4. Add environments\n\nAn environment is what a deploy targets, and it is where you decide whether a release\nneeds a human at all. On the pipeline's Environments tab, choose\nAdd environment. Name it exactly as your CI job will name it —\nstaging, production — turn Requires review\non, and set Required approvals.\n\nTwo rules decide how many approvals a release actually needs, and which people count:\n\n- An environment's Required approvals replaces the review\nplan's minimum when it is set. It does not add to it.\n\n- An environment's Review plan override beats the one attached to the\npipeline. Leave it on Plan default to inherit.\n\nWhile you are here, fill in Tags — short words of your choosing, at\nmost ten. They have no meaning to AccessFlow; they exist so you can group environments\nacross pipelines and read them back together. If you run one copy of your application\nper customer, the usual shape is one environment named prod-acme tagged\nacme and prod, so you can later ask for everything belonging\nto that customer, or everything in production, in one view.\n\nTwo more fields shape the environment's place in the pipeline. Order\nis its rung on the promotion ladder — dev before staging\nbefore production. It is filled in for you (one past the last\nenvironment) and no two environments on a pipeline may share a value; if you type one\nthat is taken, AccessFlow tells you and keeps the old order. Database\nis optional: pick the datasource that backs this environment when you want AccessFlow\nto know which database an environment stands for. Leave it empty for an environment\nthat only receives deployments — nothing about the approval or the gate changes either\nway.\n\nEnvironments are also where break-glass is switched on. An emergency\ndeploy needs both a break-glass grant on the pipeline and this opt-in on the target\nenvironment; there is no admin bypass for either half. Leave it off unless you have\nsomebody who genuinely needs it.\n\nTurn on Change ticket when releases to an environment must wait for\nan approved ServiceNow or Jira change ticket. A ticket is opened only by a ServiceNow or\nJira channel that has the Deployment submitted trigger ticked. Without\none, a release to this environment never goes out, and the form says so next to the\nswitch.\n\nPipeline settings → Environments — promotion order, tags, required approvals, review-plan override and break-glass, per environment."} {"id":"8bbd30b90a75b4f4","path":"website/docs/guides/deployment-approval/index.html","url":"https://accessflow.io/docs/guides/deployment-approval/#guide-deployment-trigger","anchor":"guide-deployment-trigger","title":"5. Grant the service account permission to trigger","section":"Guides","order":0,"tokens":228,"text":"AccessFlow Docs > Guides > Gate a deployment pipeline > 5. Grant the service account permission to trigger\n\nOn the Permissions tab, choose the service account in the\nUser box, leave Can trigger on, and click\nGrant. Without this the CI job cannot open a deployment request at all.\n\nGrants can go to groups as well as to individuals. Where both apply, the effective\ngrant is the most permissive combination of a user's own grant and every group grant\nthat has not expired.\n\nIf the account is not in the list, note that this control loads a\nsingle page of a hundred active users and searches within it, rather than querying the\nserver. On a large instance a freshly created service account may not be among them.\nCreating the account immediately before this step is the reliable way to have it\npresent."} {"id":"bf0199709e9562c8","path":"website/docs/guides/deployment-approval/index.html","url":"https://accessflow.io/docs/guides/deployment-approval/#guide-deployment-approvers","anchor":"guide-deployment-approvers","title":"6. Restrict who may approve","section":"Guides","order":0,"tokens":614,"text":"AccessFlow Docs > Guides > Gate a deployment pipeline > 6. Restrict who may approve\n\nDo not skip this step. The permission that lets somebody approve a\ndeployment is held by both ADMIN and REVIEWER. On any instance where\nreviewers exist who should not be shipping releases, a review plan that scopes approval\nis the only thing standing between them and your production deploy. It is mandatory,\nnot optional.\n\n- Create the plan. Sidebar → Security & Access →\nReview plans → Add review plan. Set\nMinimum approvals.\n\n- Add one approver row. Set Role to\nAdmin and Stage to 1.\n\n- Attach it. On the pipeline's General tab, pick it\nunder Review plan.\n\nThree things about approver rows regularly catch people out:\n\n- A plan with no approver rows restricts nothing. Attaching an empty\nplan looks like configuration but leaves the request open to everyone who holds the\ndeployment-review permission.\n\n- Deployment review is single-stage. Only rows at stage\n1 are consulted. A row at stage 2 or higher is ignored silently — no\nwarning, no error.\n\n- Review override bypasses the approver list entirely. It is an\nadmin-only permission and it is meant to work this way, which means the rule that\nultimately protects your pipeline is simply: do not make someone an admin unless they\nshould be allowed to ship.\n\nTo pin approval to one named person rather than to every admin, put their user id in\nthe row's User box. It is a plain text field expecting a UUID, and user\nids are not shown anywhere in the users screen — read one from\nGET /api/v1/admin/users.\n\nWhatever you configure, one rule cannot be configured away: the submitter can never\napprove their own deployment, admins and override holders included. That is why step 1\ninsisted on a separate account. If your pipeline also names the engineer who started it\n(the X-AccessFlow-On-Behalf-Of header, once they have agreed to it — an admin\nrecords that on the service account's On-behalf-of principals tab), that engineer cannot approve\nthe deployment either. The CI steps in this guide do not send that header; add it to your\nown request if you need it."} {"id":"ccb310152697cd07","path":"website/docs/guides/deployment-approval/index.html","url":"https://accessflow.io/docs/guides/deployment-approval/#guide-deployment-ci","anchor":"guide-deployment-ci","title":"7. Add the gate to your pipeline","section":"Guides","order":0,"tokens":652,"text":"AccessFlow Docs > Guides > Gate a deployment pipeline > 7. Add the gate to your pipeline\n\nThe pipeline's CI setup tab carries ready-made snippets with the\npipeline id already filled in. Copy from there rather than retyping the UUID. It offers\nfour, chosen from a toggle that always opens on GitHub Actions — it does not preselect\nthe provider you set in step 3: GitHub Actions, GitLab\nCI, Azure Pipelines and curl. Pipelines set\nto Jenkins, CircleCI, Bitbucket Pipelines or generic use the curl sequence.\n\nEvery provider follows the same four beats:\n\n- Submit a deployment request. It is idempotent on the CI run id, so\na retried job never opens a second review.\n\n- Poll the gate until it answers releasable.\n\n- Confirm execution, immediately before deploying.\n\n- Report the outcome once the deploy finishes.\n\n.github/workflows/deploy.yml\n\n- id: gate\nuses: bablsoft/accessflow/.github/actions/deployment-gate@v1\nwith:\naccessflow-url: https://accessflow.example.com\napi-key: ${{ secrets.ACCESSFLOW_API_KEY }}\npipeline-id: 1f0c9d02-1c2a-4a19-9f0e-6b2f8f1a4c77\nenvironment: production\nversion: ${{ github.ref_name }}\ncommit-sha: ${{ github.sha }}\nwait-timeout: 30m\n\n- run: ./deploy.sh\n\n- uses: bablsoft/accessflow/.github/actions/deployment-outcome@v1\nif: always()\nwith:\naccessflow-url: https://accessflow.example.com\napi-key: ${{ secrets.ACCESSFLOW_API_KEY }}\nrequest-id: ${{ steps.gate.outputs.request-id }}\njob-status: ${{ job.status }}\n\nThe outcome step is separate, and runs under if: always(), because a\ncomposite action has no post-run hook to report from. Pass an explicit\noutcome: ROLLED_BACK when you roll back — on an environment that requires\nreview that opens a follow-up the submitter is not allowed to close.\n\nGitLab CI, Azure Pipelines and the plain-curl sequence for anything else are documented\nalongside the full input reference in\nthe deployment gate in CI.\n\nPipeline settings → CI setup — snippets for GitHub Actions, GitLab CI, Azure Pipelines and curl, each carrying the pipeline id the job gates on."} {"id":"886799162c54ef43","path":"website/docs/guides/deployment-approval/index.html","url":"https://accessflow.io/docs/guides/deployment-approval/#guide-deployment-run","anchor":"guide-deployment-run","title":"8. Your first gated release","section":"Guides","order":0,"tokens":257,"text":"AccessFlow Docs > Guides > Gate a deployment pipeline > 8. Your first gated release\n\nRun the workflow. The gate step opens a request and then waits, printing its poll\nattempts. In AccessFlow the request appears for reviewers on the\nDeployments tab of the review queue (/reviews?tab=deployments,\nthe same page that holds query and API-request reviews), carrying the version, the\ntarget environment, the commit and the AI release-risk verdict if you enabled analysis. It never appears there\nfor the account that submitted it.\n\nAn approver opens it and approves. Within a poll interval the gate answers releasable,\nthe CI job unblocks, your deploy step runs, and the outcome step records how it went.\nThe submitter can watch the whole thing at /deployments.\n\nThe Deployments tab of the review queue — where an approver picks the release up. A deployment never appears here for its own submitter."} -{"id":"5b67475c4c415276","path":"website/docs/guides/deployment-approval/index.html","url":"https://accessflow.io/docs/guides/deployment-approval/","anchor":"","title":"If the job fails instead","section":"Guides","order":0,"tokens":645,"text":"AccessFlow Docs > Guides > Gate a deployment pipeline > If the job fails instead\n\n- The gate returns 404. The key cannot see that request — usually a\nwrong pipeline id, or a key belonging to an account with no can-trigger grant.\nAccessFlow answers 404 rather than 403 on purpose, so a key cannot probe for pipelines\nit has no business knowing about.\n\n- It waits and then times out. Nobody approved inside\nwait-timeout, or a freeze window is holding the release. Check the\nrequest at /deployments — a held release resumes on its own when the\nwindow closes, with no re-approval.\n\n- Approved, but still not releasing. Approval alone is not a green\nlight. The job branches on releasable, which also requires no active freeze\nwindow and any scheduled time to have passed. If the environment requires a change\nticket, the waiting line includes change_ticket=…. A ticket key means the\nrelease waits until AccessFlow hears that the ticket was approved — which only happens\nthrough the ticketing system's callback, so set up\nbi-directional status sync on the\nchannel. A rejected ticket blocks the release for good; submit a new deployment.\nnone linked yet means no ticket was opened — check that a ServiceNow or\nJira channel carries the deployment trigger, or the release will never go out.\n\n- The log says \"rate limited (HTTP 429), retrying\". Not a failure.\nEvery API key has a per-identity request cap (120 per minute by default; a service\naccount can be given its own), and the wrappers simply wait the number of seconds\nthe server asks for before trying again. A pipeline that hits this constantly is\npolling too fast — lengthen the poll interval, or have an admin raise the account's\nlimit on its Limits tab.\n\nA real worked example. The AccessFlow public demo deploys itself\nthrough its own gate. The workflow, the exact one-time setup it needed and the recovery\npath when the instance is down are written up in\ndocs/09-deployment.md\n→ The public demo environment.\n\nOnce a few releases have gone out this way, /deployment-versions becomes\nuseful: it lists every environment you deploy to, the version each is running now, and\nhow far behind the newest release it has fallen — filterable by the tags you set in\nstep 4."} +{"id":"5b67475c4c415276","path":"website/docs/guides/deployment-approval/index.html","url":"https://accessflow.io/docs/guides/deployment-approval/","anchor":"","title":"If the job fails instead","section":"Guides","order":0,"tokens":699,"text":"AccessFlow Docs > Guides > Gate a deployment pipeline > If the job fails instead\n\n- The gate returns 404. The key cannot see that request — usually a\nwrong pipeline id, or a key belonging to an account with no can-trigger grant.\nAccessFlow answers 404 rather than 403 on purpose, so a key cannot probe for pipelines\nit has no business knowing about.\n\n- It waits and then times out. Nobody approved inside\nwait-timeout, or a freeze window is holding the release. Check the\nrequest at /deployments — a held release resumes on its own when the\nwindow closes, with no re-approval.\n\n- Approved, but still not releasing. Approval alone is not a green\nlight. The job branches on releasable, which also requires no active freeze\nwindow and any scheduled time to have passed. If the environment requires a change\nticket, the waiting line includes change_ticket=…. A ticket key means the\nrelease waits until AccessFlow hears that the ticket was approved — which only happens\nthrough the ticketing system's callback, so set up\nbi-directional status sync on the\nchannel. A rejected ticket blocks the release for good; submit a new deployment.\nnone linked yet means no ticket was opened — check that a ServiceNow or\nJira channel carries the deployment trigger, or the release will never go out. The\ndeployment's page in AccessFlow shows the same thing: a Change ticket\ncard with the ticket's link, status and decision, and a banner that reads\nwaiting for the change ticket.\n\n- The log says \"rate limited (HTTP 429), retrying\". Not a failure.\nEvery API key has a per-identity request cap (120 per minute by default; a service\naccount can be given its own), and the wrappers simply wait the number of seconds\nthe server asks for before trying again. A pipeline that hits this constantly is\npolling too fast — lengthen the poll interval, or have an admin raise the account's\nlimit on its Limits tab.\n\nA real worked example. The AccessFlow public demo deploys itself\nthrough its own gate. The workflow, the exact one-time setup it needed and the recovery\npath when the instance is down are written up in\ndocs/09-deployment.md\n→ The public demo environment.\n\nOnce a few releases have gone out this way, /deployment-versions becomes\nuseful: it lists every environment you deploy to, the version each is running now, and\nhow far behind the newest release it has fallen — filterable by the tags you set in\nstep 4."} {"id":"e4ac3107700652aa","path":"website/docs/guides/deployment-approval/index.html","url":"https://accessflow.io/docs/guides/deployment-approval/#guide-deployment-simulation","anchor":"guide-deployment-simulation","title":"Asking \"would this release be held?\" without a release","section":"Guides","order":0,"tokens":330,"text":"AccessFlow Docs > Guides > Gate a deployment pipeline > Asking \"would this release be held?\" without a release\n\nA deployment is blocked by the gate, not only by the decision — so knowing a\nrequest would be approved does not tell you it would ship. The pipeline's\nSimulate tab (or POST /api/v1/admin/deployment-simulations)\nreplays one hypothetical deployment through\nthe very evaluator and the very releasable function the CI job blocks on, and answers\nstage by stage: the trigger grant, the freeze window, routing policies, the environment's\nown policy, who could approve, the deferred-release moment and the gate's verdict. It\naccepts an optional at, so \"would a release this Friday evening be held?\" is\nanswerable now rather than on Friday evening, and the tab puts the gate's releasable\nverdict above the step-by-step trace. Nothing is created, nothing is notified,\nnothing reaches the pipeline; it needs the same permission that manages pipelines and\nevery run is recorded in the audit log.\n\nFrom here: deployment\npipelines covers freeze windows, routing policies, break-glass and rollback reviews\nin full."} {"id":"f687c17692f24406","path":"website/docs/guides/first-query/index.html","url":"https://accessflow.io/docs/guides/first-query/#guide-first-query","anchor":"guide-first-query","title":"What you are building","section":"Guides","order":0,"tokens":163,"text":"AccessFlow Docs > Guides > Run your first governed query > What you are building\n\nA query that a person writes, an AI scores, a human approves, and AccessFlow then runs\nagainst a real database — with the whole sequence recorded. It is the shape every other\npart of the product is built on, so it is worth doing once end to end before you\nconfigure anything else.\n\nAllow about half an hour. You need AccessFlow running (the\ninstall chapter covers Docker Compose, Helm and\nrunning from source) and a database you are willing to point it at — a scratch one is\nideal."} {"id":"b3a6ad0551ecec96","path":"website/docs/guides/first-query/index.html","url":"https://accessflow.io/docs/guides/first-query/","anchor":"","title":"How does a query get approved in AccessFlow?","section":"Guides","order":0,"tokens":209,"text":"AccessFlow Docs > Guides > Run your first governed query > How does a query get approved in AccessFlow?\n\nA user picks a datasource and writes SQL. AccessFlow parses it, checks it against that\nuser's permissions, optionally has an AI model score its risk, and then holds it for\nwhoever the datasource's review plan names as an approver. Once approved it runs through\nthe proxy, under masking, row limits and any row-level rules that apply.\n\nYou will need two accounts. Nobody can approve their own query — not\neven an admin. So this walkthrough uses your admin account as the approver and creates a\nsecond, ordinary account to submit from. That is not a quirk of the demo; it is the rule\nthe whole product rests on."} diff --git a/help-corpus/manifest.json b/help-corpus/manifest.json index 82a99d661..b001cb106 100644 --- a/help-corpus/manifest.json +++ b/help-corpus/manifest.json @@ -1,10 +1,10 @@ { "schemaVersion": 1, - "corpusVersion": "304d5b2d9043", - "generatedAt": "2026-10-22T09:55:15.213Z", - "sourceCommit": "c96a84b8ae8607cce27d901bea6a484af38394c8", + "corpusVersion": "f0e3b7ec8b69", + "generatedAt": "2026-10-22T11:54:39.535Z", + "sourceCommit": "b007a2af18ea170974e18beb92f471ca3d34ebdb", "chunkCount": 608, - "sha256": "304d5b2d90433c3fb4e9880e363a55e734a3fd3f21c2b62954ad1abeaa704662", + "sha256": "f0e3b7ec8b69d6a3a56fec8449ab73da08da9341f6b2596e87a551d8ab498288", "quickReferenceSha256": "2eaa3d1f2e024c2049bf0f8a699c2208a88c1e20ad06e9503257fdcf6fea0746", "sources": [ { @@ -213,7 +213,7 @@ "url": "https://accessflow.io/docs/configuration/notifications/", "section": "Reference", "chunks": 6, - "sha256": "ca3fc0123e8761fa204a3a7d8f2d349404718e19483aea8fec4f47570c65bef0" + "sha256": "7b4a6bed919ad2ff7eea30b90f59edc571c92bacbc39abc16860920a4067f08c" }, { "path": "website/docs/configuration/review-workflows/index.html", @@ -261,7 +261,7 @@ "url": "https://accessflow.io/docs/guides/deployment-approval/", "section": "Guides", "chunks": 12, - "sha256": "9570578a1b23a82bec8ea71d6a33ecc8fcbfeff8fa233438d5c6912085d33ee0" + "sha256": "8d34f57ff26af782149c896343f7170dce952598e8b9e008593334804edea33a" }, { "path": "website/docs/guides/first-query/index.html", @@ -429,7 +429,7 @@ "url": "https://accessflow.io/docs/", "section": "Navigation", "chunks": 9, - "sha256": "553323e937f3b0caac374662ce925c3be72f5f7168c77a0ad091097e95eb1f36" + "sha256": "3ae9784aa80fd7e526f81586001a15202d8bf6e6b6daece7a8142cf67f40b240" } ] } diff --git a/website/docs/configuration/notifications/index.html b/website/docs/configuration/notifications/index.html index 0700474aa..212184d66 100644 --- a/website/docs/configuration/notifications/index.html +++ b/website/docs/configuration/notifications/index.html @@ -311,7 +311,7 @@

Where can AccessFlow send review notifications?

  • Telegram — create a bot via @BotFather, add it to the target group/channel, and configure the bot token plus numeric chat ID. Messages use MarkdownV2 formatting.
  • Microsoft Teams — paste the channel's Incoming Webhook (or Power Automate webhook) URL. Messages render as an Adaptive Card with a View in AccessFlow action button.
  • PagerDuty — paste an Events API v2 integration routing key, pick a default severity (critical / error / warning / info), and choose which triggers page the channel: AI critical-risk query, review timeout, routing-policy escalation, and/or review stalled (nobody decided within the plan’s escalation window). Other events never fire it — a nudge reminder in particular never pages. AccessFlow sends a trigger event with a query-stable dedup key so repeat events fold into one incident.
  • -
  • ServiceNow — supply the instance URL plus a username / password allowed to create incidents via the Table API, optionally an assignment group and urgency (1–3), and choose which triggers open an incident: query rejected, review timeout, and/or routing-policy escalation. Created incidents link back onto the query detail page.
  • +
  • ServiceNow — supply the instance URL plus a username / password allowed to create incidents via the Table API, optionally an assignment group and urgency (1–3), and choose which triggers open an incident: query rejected, review timeout, routing-policy escalation, and/or deployment submitted. Incidents for queries link back onto the query detail page. The deployment submitted trigger opens an incident for each deployment to an environment that requires a change ticket. That incident is the deployment’s change ticket, and it shows on the deployment’s page.
  • Jira — supply the site URL, an account email + API token, the target project key, and optionally an issue type (default Task); pick the same trigger set. Issues are created via REST v2 with an accessflow label and link back onto the query detail page.
  • diff --git a/website/docs/guides/deployment-approval/index.html b/website/docs/guides/deployment-approval/index.html index 2489b64bf..1556f857a 100644 --- a/website/docs/guides/deployment-approval/index.html +++ b/website/docs/guides/deployment-approval/index.html @@ -455,6 +455,13 @@

    4. Add environments

    environment; there is no admin bypass for either half. Leave it off unless you have somebody who genuinely needs it.

    +

    + Turn on Change ticket when releases to an environment must wait for + an approved ServiceNow or Jira change ticket. A ticket is opened only by a ServiceNow or + Jira channel that has the Deployment submitted trigger ticked. Without + one, a release to this environment never goes out, and the form says so next to the + switch. +

    @@ -648,7 +655,10 @@

    If the job fails instead

    bi-directional status sync on the channel. A rejected ticket blocks the release for good; submit a new deployment. none linked yet means no ticket was opened — check that a ServiceNow or - Jira channel carries the deployment trigger, or the release will never go out. + Jira channel carries the deployment trigger, or the release will never go out. The + deployment's page in AccessFlow shows the same thing: a Change ticket + card with the ticket's link, status and decision, and a banner that reads + waiting for the change ticket.
  • The log says "rate limited (HTTP 429), retrying". Not a failure. Every API key has a per-identity request cap (120 per minute by default; a service account can be given its own), and the wrappers simply wait the number of seconds