From 22135aed227e67c8d54e3014cddf60341ca7fa0c Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Tue, 7 Jul 2026 00:23:19 -0500 Subject: [PATCH 01/72] feat: expose member display-name lookup on workspace::api Discovery's live-session presence needs to label participants by their member display name, but members are a workspace-owned aggregate. Adds findMemberDisplayName(orgId, userId) to the WorkspaceModuleApi named interface (reads public.members, no tenant schema required) so discovery resolves names through the ACL instead of reaching in. --- .../reqsai/workspace/api/WorkspaceModuleApi.java | 13 +++++++++++++ .../application/service/WorkspaceModuleApiImpl.java | 11 +++++++++++ 2 files changed, 24 insertions(+) diff --git a/src/main/java/com/kntro/reqsai/workspace/api/WorkspaceModuleApi.java b/src/main/java/com/kntro/reqsai/workspace/api/WorkspaceModuleApi.java index a75970df..a6b61c4c 100644 --- a/src/main/java/com/kntro/reqsai/workspace/api/WorkspaceModuleApi.java +++ b/src/main/java/com/kntro/reqsai/workspace/api/WorkspaceModuleApi.java @@ -40,4 +40,17 @@ public interface WorkspaceModuleApi { * {@code orgId} path variable (e.g. discovery's {@code /api/projects/{projectId}/...}). */ boolean callerHasProjectPermission(UUID projectId, UUID userId, String permission); + + /** + * Resolves the roster display name of an active member by organization and user id. Used by + * discovery's live-session presence to label participants without reaching into the workspace + * member internals. Reads the {@code public.members} registry, so it does not require a tenant + * schema to be bound. Returns {@link Optional#empty()} when the user is not an active member of + * the organization. + * + * @param organizationId the tenant/organization id (the JWT {@code orgId}) + * @param userId the authenticated user id (the JWT {@code sub}) + * @return the member's display name, or empty when there is no active membership + */ + Optional findMemberDisplayName(UUID organizationId, UUID userId); } diff --git a/src/main/java/com/kntro/reqsai/workspace/application/service/WorkspaceModuleApiImpl.java b/src/main/java/com/kntro/reqsai/workspace/application/service/WorkspaceModuleApiImpl.java index 265a1e07..9ae7de38 100644 --- a/src/main/java/com/kntro/reqsai/workspace/application/service/WorkspaceModuleApiImpl.java +++ b/src/main/java/com/kntro/reqsai/workspace/application/service/WorkspaceModuleApiImpl.java @@ -5,11 +5,14 @@ import com.kntro.reqsai.workspace.api.WorkspaceModuleApi; import com.kntro.reqsai.shared.infrastructure.persistence.multitenancy.TenantContext; import com.kntro.reqsai.workspace.application.port.GlossaryRepository; +import com.kntro.reqsai.workspace.application.port.MemberRepository; import com.kntro.reqsai.workspace.application.port.OrganizationRepository; import com.kntro.reqsai.workspace.application.port.ProjectRepository; import com.kntro.reqsai.workspace.application.port.WorkspaceSearchRepository; import com.kntro.reqsai.workspace.domain.model.Glossary; import com.kntro.reqsai.workspace.domain.model.GlossaryTerm; +import com.kntro.reqsai.workspace.domain.model.Member; +import com.kntro.reqsai.workspace.domain.model.MemberStatus; import com.kntro.reqsai.workspace.domain.model.Permission; import com.kntro.reqsai.workspace.domain.model.Project; import com.kntro.reqsai.workspace.domain.model.ProjectConstraint; @@ -30,6 +33,7 @@ class WorkspaceModuleApiImpl implements WorkspaceModuleApi { private final WorkspaceSearchRepository searchRepository; private final OrganizationRepository organizations; private final ProjectPermissionService projectPermissions; + private final MemberRepository members; @Override @Transactional(readOnly = true) @@ -86,6 +90,13 @@ public boolean callerHasProjectPermission(UUID projectId, UUID userId, String pe .orElse(false); } + @Override + @Transactional(readOnly = true) + public Optional findMemberDisplayName(UUID organizationId, UUID userId) { + return members.findByOrganizationIdAndUserIdAndStatus(organizationId, userId, MemberStatus.ACTIVE) + .map(Member::getDisplayName); + } + /** The organization bound to the current request/callback thread, or {@code null} when none is. */ private static UUID currentTenantOrgId() { String tenant = TenantContext.getCurrentTenant(); From bec1070418e32f5ccda3fce175ccd28b41f2e5ef Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Tue, 7 Jul 2026 00:24:02 -0500 Subject: [PATCH 02/72] feat: stash authenticated orgId in STOMP session attributes STOMP session-lifecycle listeners run on the broker thread, which has no bound TenantContext. Persisting the verified orgId in the session attributes on CONNECT lets those listeners (discovery presence) resolve the tenant without re-parsing the JWT. --- .../web/websocket/StompAuthChannelInterceptor.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/main/java/com/kntro/reqsai/shared/infrastructure/web/websocket/StompAuthChannelInterceptor.java b/src/main/java/com/kntro/reqsai/shared/infrastructure/web/websocket/StompAuthChannelInterceptor.java index 64861403..9600ac63 100644 --- a/src/main/java/com/kntro/reqsai/shared/infrastructure/web/websocket/StompAuthChannelInterceptor.java +++ b/src/main/java/com/kntro/reqsai/shared/infrastructure/web/websocket/StompAuthChannelInterceptor.java @@ -16,6 +16,7 @@ import org.springframework.util.StringUtils; import java.util.List; +import java.util.Map; /** * Authenticates STOMP CONNECT frames using the same {@link TokenVerifier} as the HTTP filter. @@ -24,12 +25,19 @@ * interceptor verifies it and binds the user {@code Principal} to the session (so per-user queues and * {@code @MessageMapping} security work). A CONNECT with no token is left anonymous; a CONNECT with an * invalid token is rejected (the verifier throws). + *

+ * The verified {@code orgId} (tenant) is also stashed in the STOMP session attributes under + * {@link #ORG_ID_ATTRIBUTE}: session-lifecycle listeners (e.g. discovery presence) run on the broker + * thread with no bound {@code TenantContext}, so they read the tenant from here rather than the JWT. */ @Component @RequiredArgsConstructor @Slf4j public class StompAuthChannelInterceptor implements ChannelInterceptor { + /** STOMP session-attribute key holding the authenticated tenant/organization id (a {@code String}). */ + public static final String ORG_ID_ATTRIBUTE = "reqsai.orgId"; + private final TokenVerifier tokenVerifier; @Override @@ -43,6 +51,10 @@ public Message preSend(@NonNull Message message, @NonNull MessageChannel c token.userId(), null, token.role() != null ? List.of(new SimpleGrantedAuthority(token.role())) : List.of()); accessor.setUser(authentication); + Map attributes = accessor.getSessionAttributes(); + if (attributes != null && token.orgId() != null) { + attributes.put(ORG_ID_ATTRIBUTE, token.orgId()); + } log.debug("WebSocket CONNECT authenticated for user {} (tenant {})", token.userId(), token.orgId()); } From 1f0f891ed93d6686e8998f4bb45fa5681b0e4df1 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Tue, 7 Jul 2026 00:25:08 -0500 Subject: [PATCH 03/72] feat: add PRESENCE_STATE session realtime message contract Introduces the SessionParticipant record and the SessionPresenceMessage snapshot (added to the sealed SessionRealtimeMessage hierarchy and the SessionEventType discriminator). The roster travels on the existing per-session topic so the client keeps a single subscription and switches on type, consistent with the other session events. --- .../notification/SessionEventType.java | 10 +++++- .../messages/SessionParticipant.java | 14 ++++++++ .../messages/SessionPresenceMessage.java | 33 +++++++++++++++++++ .../messages/SessionRealtimeMessage.java | 2 +- 4 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/kntro/reqsai/discovery/interfaces/notification/messages/SessionParticipant.java create mode 100644 src/main/java/com/kntro/reqsai/discovery/interfaces/notification/messages/SessionPresenceMessage.java diff --git a/src/main/java/com/kntro/reqsai/discovery/interfaces/notification/SessionEventType.java b/src/main/java/com/kntro/reqsai/discovery/interfaces/notification/SessionEventType.java index 39c7ea61..6be5eb0b 100644 --- a/src/main/java/com/kntro/reqsai/discovery/interfaces/notification/SessionEventType.java +++ b/src/main/java/com/kntro/reqsai/discovery/interfaces/notification/SessionEventType.java @@ -61,5 +61,13 @@ public enum SessionEventType { SUGGESTION_ACCEPTED, /** The analyst dismissed a suggestion (no backlog change). */ - SUGGESTION_DISMISSED + SUGGESTION_DISMISSED, + + // Live presence + + /** + * The roster of users currently viewing the live session changed (someone joined or left). + * Carries the full participant list so the client can render it idempotently. + */ + PRESENCE_STATE } diff --git a/src/main/java/com/kntro/reqsai/discovery/interfaces/notification/messages/SessionParticipant.java b/src/main/java/com/kntro/reqsai/discovery/interfaces/notification/messages/SessionParticipant.java new file mode 100644 index 00000000..a129bef2 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/discovery/interfaces/notification/messages/SessionParticipant.java @@ -0,0 +1,14 @@ +package com.kntro.reqsai.discovery.interfaces.notification.messages; + +import java.util.UUID; + +/** + * One user currently present in a live discovery session, as carried by {@link SessionPresenceMessage}. + * + * @param userId the participant's user id (JWT {@code sub}) + * @param displayName the member display name resolved from the workspace roster; may fall back to a + * generic label when the membership cannot be resolved + * @param avatarUrl the public avatar serve path for the user (loadable directly by an {@code }) + */ +public record SessionParticipant(UUID userId, String displayName, String avatarUrl) { +} diff --git a/src/main/java/com/kntro/reqsai/discovery/interfaces/notification/messages/SessionPresenceMessage.java b/src/main/java/com/kntro/reqsai/discovery/interfaces/notification/messages/SessionPresenceMessage.java new file mode 100644 index 00000000..f64d0be7 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/discovery/interfaces/notification/messages/SessionPresenceMessage.java @@ -0,0 +1,33 @@ +package com.kntro.reqsai.discovery.interfaces.notification.messages; + +import com.kntro.reqsai.discovery.interfaces.notification.SessionEventType; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +/** + * WebSocket payload for {@link SessionEventType#PRESENCE_STATE}: the full roster of users currently + * viewing a live discovery session. + *

+ * The message is a snapshot (the complete list, not a delta) so the client renders it + * idempotently and never drifts if it misses an intermediate join/leave. {@code count} is the number + * of distinct participants — the same user viewing from two tabs appears once. + */ +public record SessionPresenceMessage( + UUID sessionId, + List participants, + int count, + Instant occurredAt +) implements SessionRealtimeMessage { + + /** Builds a presence snapshot, deriving {@code count} from the participant list. */ + public static SessionPresenceMessage of(UUID sessionId, List participants, Instant occurredAt) { + return new SessionPresenceMessage(sessionId, List.copyOf(participants), participants.size(), occurredAt); + } + + @Override + public SessionEventType type() { + return SessionEventType.PRESENCE_STATE; + } +} diff --git a/src/main/java/com/kntro/reqsai/discovery/interfaces/notification/messages/SessionRealtimeMessage.java b/src/main/java/com/kntro/reqsai/discovery/interfaces/notification/messages/SessionRealtimeMessage.java index 72ccdc01..3c16ec5e 100644 --- a/src/main/java/com/kntro/reqsai/discovery/interfaces/notification/messages/SessionRealtimeMessage.java +++ b/src/main/java/com/kntro/reqsai/discovery/interfaces/notification/messages/SessionRealtimeMessage.java @@ -12,7 +12,7 @@ * correct UI component. The {@code sealed} hierarchy makes the full set of messages explicit and * lets serializers/consumers reason about it exhaustively at compile time. */ -public sealed interface SessionRealtimeMessage permits SessionStatusChangedMessage, SessionProcessingFailedMessage, SessionStoryGeneratedMessage, SessionTranscriptSegmentMessage, SessionSuggestionMessage, SessionLifecycleMessage { +public sealed interface SessionRealtimeMessage permits SessionStatusChangedMessage, SessionProcessingFailedMessage, SessionStoryGeneratedMessage, SessionTranscriptSegmentMessage, SessionSuggestionMessage, SessionLifecycleMessage, SessionPresenceMessage { /** Session this update belongs to (matches the subscribed topic). */ UUID sessionId(); From fd0d5e5907bf68d0f923d98f6fb1d1ed248cca96 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Tue, 7 Jul 2026 00:28:17 -0500 Subject: [PATCH 04/72] feat: track live discovery-session presence over STOMP Adds an in-process presence layer for the discovery module: SessionPresenceRegistry keeps who is subscribed to each session topic, SessionPresenceTracker drives it from STOMP subscribe/unsubscribe/ disconnect events and rebroadcasts the roster snapshot, and SessionParticipantResolver labels participants via the workspace ACL with a Caffeine cache. No Redis: presence is per-connection JVM state, same single-instance trade-off as the SIMPLE broker. --- .../notification/SessionTopics.java | 9 ++ .../presence/SessionParticipantResolver.java | 50 ++++++ .../presence/SessionPresenceRegistry.java | 121 ++++++++++++++ .../presence/SessionPresenceTracker.java | 147 ++++++++++++++++++ 4 files changed, 327 insertions(+) create mode 100644 src/main/java/com/kntro/reqsai/discovery/interfaces/websocket/presence/SessionParticipantResolver.java create mode 100644 src/main/java/com/kntro/reqsai/discovery/interfaces/websocket/presence/SessionPresenceRegistry.java create mode 100644 src/main/java/com/kntro/reqsai/discovery/interfaces/websocket/presence/SessionPresenceTracker.java diff --git a/src/main/java/com/kntro/reqsai/discovery/application/notification/SessionTopics.java b/src/main/java/com/kntro/reqsai/discovery/application/notification/SessionTopics.java index c5282775..27c78058 100644 --- a/src/main/java/com/kntro/reqsai/discovery/application/notification/SessionTopics.java +++ b/src/main/java/com/kntro/reqsai/discovery/application/notification/SessionTopics.java @@ -19,6 +19,15 @@ public final class SessionTopics { private SessionTopics() { } + /** + * The logical topic prefix for per-session destinations (no broker prefix). Presence tracking + * matches subscribe destinations against {@code /topic/} + this value to recognize which session + * a client is viewing. + */ + public static String sessionsPrefix() { + return SESSIONS_PREFIX; + } + /** * Logical topic carrying every realtime update for one discovery session. * diff --git a/src/main/java/com/kntro/reqsai/discovery/interfaces/websocket/presence/SessionParticipantResolver.java b/src/main/java/com/kntro/reqsai/discovery/interfaces/websocket/presence/SessionParticipantResolver.java new file mode 100644 index 00000000..461c94ec --- /dev/null +++ b/src/main/java/com/kntro/reqsai/discovery/interfaces/websocket/presence/SessionParticipantResolver.java @@ -0,0 +1,50 @@ +package com.kntro.reqsai.discovery.interfaces.websocket.presence; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.kntro.reqsai.discovery.interfaces.notification.messages.SessionParticipant; +import com.kntro.reqsai.shared.application.avatar.AvatarPaths; +import com.kntro.reqsai.workspace.api.WorkspaceModuleApi; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.UUID; + +/** + * Turns a bare {@code userId} (plus the connection's tenant) into a display-ready + * {@link SessionParticipant} for the presence roster. + *

+ * The display name comes from the workspace member roster through the {@code workspace::api} ACL and + * is Caffeine-cached (keyed by tenant + user) so a chatty stream of join/leave + * events does not hit the database on every transition. The avatar URL is deterministic + * ({@link AvatarPaths#user(UUID)}) and needs no lookup. + */ +@Component +@RequiredArgsConstructor +public class SessionParticipantResolver { + + /** Shown when a user id cannot be matched to an active membership (e.g. removed mid-session). */ + static final String UNKNOWN_DISPLAY_NAME = "Participant"; + + private final WorkspaceModuleApi workspace; + + private final Cache displayNames = Caffeine.newBuilder() + .maximumSize(10_000) + .expireAfterWrite(Duration.ofMinutes(30)) + .build(); + + /** + * Resolves the participant view for {@code userId} in {@code orgId}. Never returns {@code null}: + * an unresolved membership falls back to a generic label so a present user is still shown. + */ + public SessionParticipant resolve(UUID orgId, UUID userId) { + String displayName = displayNames.get(cacheKey(orgId, userId), key -> + workspace.findMemberDisplayName(orgId, userId).orElse(UNKNOWN_DISPLAY_NAME)); + return new SessionParticipant(userId, displayName, AvatarPaths.user(userId)); + } + + private static String cacheKey(UUID orgId, UUID userId) { + return orgId + ":" + userId; + } +} diff --git a/src/main/java/com/kntro/reqsai/discovery/interfaces/websocket/presence/SessionPresenceRegistry.java b/src/main/java/com/kntro/reqsai/discovery/interfaces/websocket/presence/SessionPresenceRegistry.java new file mode 100644 index 00000000..409f2cba --- /dev/null +++ b/src/main/java/com/kntro/reqsai/discovery/interfaces/websocket/presence/SessionPresenceRegistry.java @@ -0,0 +1,121 @@ +package com.kntro.reqsai.discovery.interfaces.websocket.presence; + +import org.springframework.stereotype.Component; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * In-process registry of who is currently viewing each live discovery session, driven by STOMP + * subscribe/unsubscribe/disconnect events. Deliberately not Redis: presence is + * ephemeral, per-connection state, so it lives in the JVM alongside the in-memory broker. + * + *

Scaling note: like the {@code SIMPLE} broker (ADR-0007), this registry only + * sees connections on the local JVM. With multiple instances behind a {@code RELAY} broker, each + * instance tracks its own slice; a fully global roster would need the broker's shared state. This is + * acceptable at single-instance scale and is the same trade-off already accepted for broadcasts. + * + *

Presence is keyed by discovery {@code sessionId}. A single user viewing from two browser tabs + * (two STOMP sessions) counts once — {@link #roster(UUID)} returns distinct user ids. All mutating + * methods return whether the visible roster for a session actually changed, so the caller only + * re-broadcasts on real transitions. + */ +@Component +public class SessionPresenceRegistry { + + /** discovery sessionId → (stompSessionId → userId) of everyone currently subscribed. */ + private final Map> presenceBySession = new ConcurrentHashMap<>(); + + /** stompSessionId → (subscriptionId → discovery sessionId), to resolve unsubscribe/disconnect. */ + private final Map> subscriptionsByStomp = new ConcurrentHashMap<>(); + + /** + * Records that {@code userId} subscribed to {@code sessionId} on a STOMP connection. + * + * @return {@code true} when this made the user newly present in the session (roster grew) + */ + public synchronized boolean join(UUID sessionId, String stompSessionId, String subscriptionId, UUID userId) { + subscriptionsByStomp + .computeIfAbsent(stompSessionId, k -> new ConcurrentHashMap<>()) + .put(subscriptionId, sessionId); + boolean userWasPresent = isUserPresent(sessionId, userId); + presenceBySession + .computeIfAbsent(sessionId, k -> new ConcurrentHashMap<>()) + .put(stompSessionId, userId); + return !userWasPresent; + } + + /** + * Removes a single subscription (STOMP UNSUBSCRIBE). If it was the connection's last subscription + * to that session, the connection stops being present. + * + * @return the affected session id when the visible roster changed, otherwise empty + */ + public synchronized Optional leaveSubscription(String stompSessionId, String subscriptionId) { + Map subs = subscriptionsByStomp.get(stompSessionId); + if (subs == null) { + return Optional.empty(); + } + UUID sessionId = subs.remove(subscriptionId); + if (subs.isEmpty()) { + subscriptionsByStomp.remove(stompSessionId); + } + if (sessionId == null || subs.containsValue(sessionId)) { + // Unknown subscription, or the connection still views this session via another subscription. + return Optional.empty(); + } + return removeConnectionFromSession(sessionId, stompSessionId); + } + + /** + * Removes a whole STOMP connection (DISCONNECT), dropping it from every session it viewed. + * + * @return the set of sessions whose visible roster changed + */ + public synchronized Set disconnect(String stompSessionId) { + Map subs = subscriptionsByStomp.remove(stompSessionId); + if (subs == null) { + return Set.of(); + } + Set changed = new LinkedHashSet<>(); + for (UUID sessionId : Set.copyOf(subs.values())) { + removeConnectionFromSession(sessionId, stompSessionId).ifPresent(changed::add); + } + return changed; + } + + /** Distinct user ids currently present in {@code sessionId}, insertion-ordered. */ + public synchronized List roster(UUID sessionId) { + Map present = presenceBySession.get(sessionId); + if (present == null) { + return List.of(); + } + return List.copyOf(new LinkedHashSet<>(present.values())); + } + + private Optional removeConnectionFromSession(UUID sessionId, String stompSessionId) { + Map present = presenceBySession.get(sessionId); + if (present == null) { + return Optional.empty(); + } + UUID removedUser = present.remove(stompSessionId); + if (present.isEmpty()) { + presenceBySession.remove(sessionId); + } + if (removedUser == null) { + return Optional.empty(); + } + // Roster only changed if that user is no longer present via another connection (another tab). + return present.containsValue(removedUser) ? Optional.empty() : Optional.of(sessionId); + } + + private boolean isUserPresent(UUID sessionId, UUID userId) { + Map present = presenceBySession.get(sessionId); + return present != null && present.containsValue(userId); + } +} diff --git a/src/main/java/com/kntro/reqsai/discovery/interfaces/websocket/presence/SessionPresenceTracker.java b/src/main/java/com/kntro/reqsai/discovery/interfaces/websocket/presence/SessionPresenceTracker.java new file mode 100644 index 00000000..9ed8d524 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/discovery/interfaces/websocket/presence/SessionPresenceTracker.java @@ -0,0 +1,147 @@ +package com.kntro.reqsai.discovery.interfaces.websocket.presence; + +import com.kntro.reqsai.discovery.application.notification.SessionTopics; +import com.kntro.reqsai.discovery.interfaces.notification.messages.SessionParticipant; +import com.kntro.reqsai.discovery.interfaces.notification.messages.SessionPresenceMessage; +import com.kntro.reqsai.shared.application.avatar.AvatarPaths; +import com.kntro.reqsai.shared.application.notification.RealtimeNotifier; +import com.kntro.reqsai.shared.infrastructure.web.websocket.StompAuthChannelInterceptor; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.messaging.SessionDisconnectEvent; +import org.springframework.web.socket.messaging.SessionSubscribeEvent; +import org.springframework.web.socket.messaging.SessionUnsubscribeEvent; + +import java.security.Principal; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * Live-presence tracker for discovery sessions. Listens to STOMP lifecycle events and keeps + * {@link SessionPresenceRegistry} in sync: a SUBSCRIBE to {@code /topic/sessions/{id}} marks the user + * present, an UNSUBSCRIBE or DISCONNECT removes them. On every real roster change it rebroadcasts the + * full {@link SessionPresenceMessage} snapshot on that session's topic, so all viewers converge. + *

+ * Presence rides the same per-session topic the client already subscribes to — no extra subscription, + * and the subscription itself is the presence signal (viewing the live session = present). + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class SessionPresenceTracker { + + private static final String SESSION_DESTINATION_PREFIX = "/topic/" + SessionTopics.sessionsPrefix(); + + private final SessionPresenceRegistry registry; + private final SessionParticipantResolver resolver; + private final RealtimeNotifier notifier; + + @EventListener + void onSubscribe(SessionSubscribeEvent event) { + StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage()); + UUID sessionId = parseSessionId(accessor.getDestination()); + UUID userId = userId(accessor.getUser()); + UUID orgId = orgId(accessor.getSessionAttributes()); + String stompSessionId = accessor.getSessionId(); + String subscriptionId = accessor.getSubscriptionId(); + if (sessionId == null || userId == null || orgId == null + || stompSessionId == null || subscriptionId == null) { + return; + } + if (registry.join(sessionId, stompSessionId, subscriptionId, userId)) { + broadcast(sessionId, orgId); + } + } + + @EventListener + void onUnsubscribe(SessionUnsubscribeEvent event) { + StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage()); + String stompSessionId = accessor.getSessionId(); + String subscriptionId = accessor.getSubscriptionId(); + if (stompSessionId == null || subscriptionId == null) { + return; + } + UUID orgId = orgId(accessor.getSessionAttributes()); + registry.leaveSubscription(stompSessionId, subscriptionId) + .ifPresent(sessionId -> broadcast(sessionId, orgId)); + } + + @EventListener + void onDisconnect(SessionDisconnectEvent event) { + StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage()); + String stompSessionId = event.getSessionId(); + if (stompSessionId == null) { + return; + } + UUID orgId = orgId(accessor.getSessionAttributes()); + for (UUID sessionId : registry.disconnect(stompSessionId)) { + broadcast(sessionId, orgId); + } + } + + /** + * Rebroadcasts the current roster for a session. {@code orgId} comes from the connection that + * triggered the change; since a discovery session is single-tenant, every participant resolves + * under the same organization. A missing {@code orgId} still broadcasts an anonymous roster so + * the count stays correct. + */ + private void broadcast(UUID sessionId, UUID orgId) { + List participants = registry.roster(sessionId).stream() + .map(userId -> resolveParticipant(orgId, userId)) + .toList(); + notifier.broadcast(SessionTopics.of(sessionId), + SessionPresenceMessage.of(sessionId, participants, Instant.now())); + log.debug("Presence for session {}: {} participant(s)", sessionId, participants.size()); + } + + private SessionParticipant resolveParticipant(UUID orgId, UUID userId) { + if (orgId == null) { + return new SessionParticipant(userId, SessionParticipantResolver.UNKNOWN_DISPLAY_NAME, + AvatarPaths.user(userId)); + } + return resolver.resolve(orgId, userId); + } + + private static UUID parseSessionId(String destination) { + if (destination == null || !destination.startsWith(SESSION_DESTINATION_PREFIX)) { + return null; + } + String raw = destination.substring(SESSION_DESTINATION_PREFIX.length()); + try { + return UUID.fromString(raw); + } catch (IllegalArgumentException ex) { + return null; + } + } + + private static UUID userId(Principal principal) { + if (principal == null) { + return null; + } + try { + return UUID.fromString(principal.getName()); + } catch (IllegalArgumentException ex) { + return null; + } + } + + private static UUID orgId(Map attributes) { + if (attributes == null) { + return null; + } + Object value = attributes.get(StompAuthChannelInterceptor.ORG_ID_ATTRIBUTE); + if (!(value instanceof String raw)) { + return null; + } + try { + return UUID.fromString(raw); + } catch (IllegalArgumentException ex) { + return null; + } + } +} From 55377cd43ce394c6aac0ac617159c8cd5fcf4130 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Tue, 7 Jul 2026 00:29:33 -0500 Subject: [PATCH 05/72] test: cover session presence registry transitions Verifies join/leave semantics, multi-tab dedup (a user counts once and stays present until their last connection leaves), disconnect fan-out across sessions, and unknown-connection no-ops. Switches the inner presence map to a LinkedHashMap so the roster keeps a stable join order. --- .../presence/SessionPresenceRegistry.java | 9 +- .../presence/SessionPresenceRegistryTest.java | 95 +++++++++++++++++++ 2 files changed, 102 insertions(+), 2 deletions(-) create mode 100644 src/test/java/com/kntro/reqsai/discovery/interfaces/websocket/presence/SessionPresenceRegistryTest.java diff --git a/src/main/java/com/kntro/reqsai/discovery/interfaces/websocket/presence/SessionPresenceRegistry.java b/src/main/java/com/kntro/reqsai/discovery/interfaces/websocket/presence/SessionPresenceRegistry.java index 409f2cba..7d39b918 100644 --- a/src/main/java/com/kntro/reqsai/discovery/interfaces/websocket/presence/SessionPresenceRegistry.java +++ b/src/main/java/com/kntro/reqsai/discovery/interfaces/websocket/presence/SessionPresenceRegistry.java @@ -2,6 +2,7 @@ import org.springframework.stereotype.Component; +import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; @@ -28,7 +29,11 @@ @Component public class SessionPresenceRegistry { - /** discovery sessionId → (stompSessionId → userId) of everyone currently subscribed. */ + /** + * discovery sessionId → (stompSessionId → userId) of everyone currently subscribed. The inner map + * is a {@link LinkedHashMap} so the roster keeps a stable join order (avatars don't reshuffle); + * safe because every access below is {@code synchronized}. + */ private final Map> presenceBySession = new ConcurrentHashMap<>(); /** stompSessionId → (subscriptionId → discovery sessionId), to resolve unsubscribe/disconnect. */ @@ -45,7 +50,7 @@ public synchronized boolean join(UUID sessionId, String stompSessionId, String s .put(subscriptionId, sessionId); boolean userWasPresent = isUserPresent(sessionId, userId); presenceBySession - .computeIfAbsent(sessionId, k -> new ConcurrentHashMap<>()) + .computeIfAbsent(sessionId, k -> new LinkedHashMap<>()) .put(stompSessionId, userId); return !userWasPresent; } diff --git a/src/test/java/com/kntro/reqsai/discovery/interfaces/websocket/presence/SessionPresenceRegistryTest.java b/src/test/java/com/kntro/reqsai/discovery/interfaces/websocket/presence/SessionPresenceRegistryTest.java new file mode 100644 index 00000000..77cc6532 --- /dev/null +++ b/src/test/java/com/kntro/reqsai/discovery/interfaces/websocket/presence/SessionPresenceRegistryTest.java @@ -0,0 +1,95 @@ +package com.kntro.reqsai.discovery.interfaces.websocket.presence; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +/** Unit tests for the presence bookkeeping: join/leave transitions, multi-tab dedup and disconnect. */ +class SessionPresenceRegistryTest { + + private final SessionPresenceRegistry registry = new SessionPresenceRegistry(); + + private final UUID session = UUID.randomUUID(); + private final UUID alice = UUID.randomUUID(); + private final UUID bob = UUID.randomUUID(); + + @Test + @DisplayName("first subscribe makes the user present and reports a roster change") + void firstSubscribeAddsUser() { + boolean changed = registry.join(session, "stomp-1", "sub-1", alice); + + assertThat(changed).isTrue(); + assertThat(registry.roster(session)).containsExactly(alice); + } + + @Test + @DisplayName("distinct users accumulate in the roster") + void distinctUsersAccumulate() { + registry.join(session, "stomp-1", "sub-1", alice); + boolean changed = registry.join(session, "stomp-2", "sub-1", bob); + + assertThat(changed).isTrue(); + assertThat(registry.roster(session)).containsExactly(alice, bob); + } + + @Test + @DisplayName("same user on a second tab does not change the visible roster") + void secondTabIsDeduped() { + registry.join(session, "stomp-1", "sub-1", alice); + boolean changed = registry.join(session, "stomp-2", "sub-1", alice); + + assertThat(changed).isFalse(); + assertThat(registry.roster(session)).containsExactly(alice); + } + + @Test + @DisplayName("unsubscribing the last subscription removes the user and reports the change") + void unsubscribeRemovesUser() { + registry.join(session, "stomp-1", "sub-1", alice); + + var affected = registry.leaveSubscription("stomp-1", "sub-1"); + + assertThat(affected).contains(session); + assertThat(registry.roster(session)).isEmpty(); + } + + @Test + @DisplayName("a user stays present until their last tab leaves") + void userStaysUntilLastTabLeaves() { + registry.join(session, "stomp-1", "sub-1", alice); + registry.join(session, "stomp-2", "sub-1", alice); + + var firstLeave = registry.leaveSubscription("stomp-1", "sub-1"); + assertThat(firstLeave).isEmpty(); + assertThat(registry.roster(session)).containsExactly(alice); + + var lastLeave = registry.leaveSubscription("stomp-2", "sub-1"); + assertThat(lastLeave).contains(session); + assertThat(registry.roster(session)).isEmpty(); + } + + @Test + @DisplayName("disconnect drops the connection from every session it viewed") + void disconnectDropsFromAllSessions() { + UUID otherSession = UUID.randomUUID(); + registry.join(session, "stomp-1", "sub-1", alice); + registry.join(otherSession, "stomp-1", "sub-2", alice); + registry.join(session, "stomp-2", "sub-1", bob); + + var affected = registry.disconnect("stomp-1"); + + assertThat(affected).containsExactlyInAnyOrder(session, otherSession); + assertThat(registry.roster(session)).containsExactly(bob); + assertThat(registry.roster(otherSession)).isEmpty(); + } + + @Test + @DisplayName("unknown subscription/connection is a no-op") + void unknownIsNoOp() { + assertThat(registry.leaveSubscription("ghost", "sub-1")).isEmpty(); + assertThat(registry.disconnect("ghost")).isEmpty(); + } +} From e95b0b66c4de9640e3368a6b6daa0c58736d6666 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Tue, 7 Jul 2026 00:30:31 -0500 Subject: [PATCH 06/72] docs: document live session presence Adds a presence section to REALTIME.md (STOMP-event-driven, in-process registry, single-topic PRESENCE_STATE, multi-instance caveat) and a CHANGELOG entry under [Unreleased] for feature/discovery-presence. --- CHANGELOG.md | 17 +++++++++++++++++ docs/REALTIME.md | 20 ++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d542c35..66e3aa3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,23 @@ follows [Semantic Versioning](https://semver.org/). _Bounded-context implementation (iam, billing, workspace, discovery, gateway) in progress._ +### Added (Live session presence — `feature/discovery-presence`) + +- **Real-time presence for live discovery sessions** — the users currently viewing a live session are + now tracked and broadcast so every participant sees who else is present. A new `PRESENCE_STATE` + event on the existing per-session topic (`/topic/sessions/{id}`) carries a `SessionPresenceMessage` + snapshot: the full participant list (`userId`, `displayName`, `avatarUrl`) plus a distinct `count`. + Presence is driven entirely by STOMP lifecycle events — subscribing to the session topic **is** the + presence signal; unsubscribe/disconnect removes the user; the same user across two tabs counts once. +- **`WorkspaceModuleApi.findMemberDisplayName(orgId, userId)`** — new read on the `workspace::api` ACL + so discovery labels participants from the member roster (`public.members`) without reaching into + workspace internals; the result is Caffeine-cached per tenant+user. +- **No Redis / no schema change** — the presence roster is ephemeral in-process state (a + `SessionPresenceRegistry` alongside the in-memory broker). Like the `SIMPLE` broker (ADR-0007) it is + per-JVM; a fully global roster across multiple instances would require the shared `RELAY` broker's + state. The authenticated `orgId` is stashed in the STOMP session attributes on CONNECT so the + broker-thread listeners can resolve the tenant. + ### Added (Backlog / Glossary / Constraints listing — `feature/discovery-session-control`) - **User-story backlog list filters + search** — `GET /projects/{projectId}/stories` now accepts five diff --git a/docs/REALTIME.md b/docs/REALTIME.md index a25905f7..a361970f 100644 --- a/docs/REALTIME.md +++ b/docs/REALTIME.md @@ -84,6 +84,26 @@ client.activate(); | `/topic` | server → clients | broadcast (many subscribers) | | `/user` | server → one user | per-user queue (`sendToUser`, resolved per principal) | +## Live presence (who is viewing a session) + +Discovery tracks who is currently viewing a **live** session and broadcasts the roster so every +participant sees the others. It is built entirely on the STOMP lifecycle — there is **no** extra +subscription and **no** client→server message: + +- **Signal:** a client's SUBSCRIBE to `/topic/sessions/{id}` *is* "I am present". `SessionPresenceTracker` + listens to `SessionSubscribeEvent` / `SessionUnsubscribeEvent` / `SessionDisconnectEvent`. +- **State:** `SessionPresenceRegistry` holds, per session, which connections are present (a user across + two tabs counts once). It is in-process — **not** Redis — and, like the `SIMPLE` broker, per-JVM. +- **Broadcast:** on any real roster change the tracker sends a `PRESENCE_STATE` message + (`SessionPresenceMessage`: full participant snapshot + `count`) back on the same `sessions/{id}` topic. + The client keeps its one subscription and switches on `type` (see the single-topic pattern below). +- **Identity:** the CONNECT interceptor stashes the tenant `orgId` in the STOMP session attributes; + the tracker resolves each `userId` to a display name via `WorkspaceModuleApi.findMemberDisplayName` + (Caffeine-cached) and a deterministic `avatarUrl` (`/api/users/{userId}/avatar`). + +> Multi-instance caveat: because the registry is per-JVM (same as the `SIMPLE` broker), a global roster +> across several ECS tasks needs the shared `RELAY` broker's state — acceptable at single-instance scale. + ## Scaling: SIMPLE vs. RELAY (important for ECS) The default **`SIMPLE`** broker is in-memory and only knows connections on the **local JVM**. With more From 4a14bb9d4906fab7586dace3b79bd6de2769bbe7 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Tue, 7 Jul 2026 11:49:59 -0500 Subject: [PATCH 07/72] test: cover presence resolver caching and STOMP tracker broadcasts Adds SessionParticipantResolverTest (name + deterministic avatar url, Caffeine caching hits the workspace once, generic fallback when the membership is unknown) and SessionPresenceTrackerTest (subscribe to a session topic broadcasts the roster, unrelated destinations are ignored, unsubscribe/disconnect rebroadcast an empty roster). --- .../SessionParticipantResolverTest.java | 66 ++++++++ .../presence/SessionPresenceTrackerTest.java | 152 ++++++++++++++++++ 2 files changed, 218 insertions(+) create mode 100644 src/test/java/com/kntro/reqsai/discovery/interfaces/websocket/presence/SessionParticipantResolverTest.java create mode 100644 src/test/java/com/kntro/reqsai/discovery/interfaces/websocket/presence/SessionPresenceTrackerTest.java diff --git a/src/test/java/com/kntro/reqsai/discovery/interfaces/websocket/presence/SessionParticipantResolverTest.java b/src/test/java/com/kntro/reqsai/discovery/interfaces/websocket/presence/SessionParticipantResolverTest.java new file mode 100644 index 00000000..95c42fa6 --- /dev/null +++ b/src/test/java/com/kntro/reqsai/discovery/interfaces/websocket/presence/SessionParticipantResolverTest.java @@ -0,0 +1,66 @@ +package com.kntro.reqsai.discovery.interfaces.websocket.presence; + +import com.kntro.reqsai.discovery.interfaces.notification.messages.SessionParticipant; +import com.kntro.reqsai.workspace.api.WorkspaceModuleApi; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@DisplayName("Presence: participant resolver") +@ExtendWith(MockitoExtension.class) +class SessionParticipantResolverTest { + + @Mock + private WorkspaceModuleApi workspace; + + private final UUID orgId = UUID.randomUUID(); + private final UUID userId = UUID.randomUUID(); + + @Test + @DisplayName("resolves the member name and a deterministic avatar url") + void resolvesNameAndAvatar() { + var resolver = new SessionParticipantResolver(workspace); + when(workspace.findMemberDisplayName(orgId, userId)).thenReturn(Optional.of("Ana Torres")); + + SessionParticipant participant = resolver.resolve(orgId, userId); + + assertThat(participant.userId()).isEqualTo(userId); + assertThat(participant.displayName()).isEqualTo("Ana Torres"); + assertThat(participant.avatarUrl()).isEqualTo("/api/users/" + userId + "/avatar"); + } + + @Test + @DisplayName("caches the display name so repeated resolves hit the workspace once") + void cachesDisplayName() { + var resolver = new SessionParticipantResolver(workspace); + when(workspace.findMemberDisplayName(orgId, userId)).thenReturn(Optional.of("Ana Torres")); + + resolver.resolve(orgId, userId); + resolver.resolve(orgId, userId); + resolver.resolve(orgId, userId); + + verify(workspace, times(1)).findMemberDisplayName(orgId, userId); + } + + @Test + @DisplayName("falls back to a generic label when the membership cannot be resolved") + void fallsBackWhenUnknown() { + var resolver = new SessionParticipantResolver(workspace); + when(workspace.findMemberDisplayName(orgId, userId)).thenReturn(Optional.empty()); + + SessionParticipant participant = resolver.resolve(orgId, userId); + + assertThat(participant.displayName()).isEqualTo(SessionParticipantResolver.UNKNOWN_DISPLAY_NAME); + assertThat(participant.avatarUrl()).isEqualTo("/api/users/" + userId + "/avatar"); + } +} diff --git a/src/test/java/com/kntro/reqsai/discovery/interfaces/websocket/presence/SessionPresenceTrackerTest.java b/src/test/java/com/kntro/reqsai/discovery/interfaces/websocket/presence/SessionPresenceTrackerTest.java new file mode 100644 index 00000000..ef584529 --- /dev/null +++ b/src/test/java/com/kntro/reqsai/discovery/interfaces/websocket/presence/SessionPresenceTrackerTest.java @@ -0,0 +1,152 @@ +package com.kntro.reqsai.discovery.interfaces.websocket.presence; + +import com.kntro.reqsai.discovery.application.notification.SessionTopics; +import com.kntro.reqsai.discovery.interfaces.notification.messages.SessionParticipant; +import com.kntro.reqsai.discovery.interfaces.notification.messages.SessionPresenceMessage; +import com.kntro.reqsai.shared.application.notification.RealtimeNotifier; +import com.kntro.reqsai.shared.infrastructure.web.websocket.StompAuthChannelInterceptor; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.messaging.Message; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.web.socket.messaging.SessionDisconnectEvent; +import org.springframework.web.socket.messaging.SessionSubscribeEvent; +import org.springframework.web.socket.messaging.SessionUnsubscribeEvent; + +import java.security.Principal; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@DisplayName("Presence: STOMP tracker") +@ExtendWith(MockitoExtension.class) +class SessionPresenceTrackerTest { + + @Mock + private SessionParticipantResolver resolver; + + @Mock + private RealtimeNotifier notifier; + + private SessionPresenceTracker tracker; + + private final UUID sessionId = UUID.randomUUID(); + private final UUID orgId = UUID.randomUUID(); + private final UUID alice = UUID.randomUUID(); + + @BeforeEach + void setUp() { + tracker = new SessionPresenceTracker(new SessionPresenceRegistry(), resolver, notifier); + } + + @Test + @DisplayName("a subscribe to a session topic broadcasts the roster") + void subscribeBroadcastsRoster() { + when(resolver.resolve(orgId, alice)) + .thenReturn(new SessionParticipant(alice, "Ana", "/api/users/" + alice + "/avatar")); + + tracker.onSubscribe(subscribe("stomp-1", "sub-1", "/topic/" + SessionTopics.of(sessionId))); + + SessionPresenceMessage message = captureBroadcast(); + assertThat(message.sessionId()).isEqualTo(sessionId); + assertThat(message.count()).isEqualTo(1); + assertThat(message.participants()).singleElement() + .satisfies(p -> assertThat(p.displayName()).isEqualTo("Ana")); + } + + @Test + @DisplayName("a subscribe to an unrelated destination is ignored") + void ignoresUnrelatedDestination() { + tracker.onSubscribe(subscribe("stomp-1", "sub-1", "/topic/projects/" + UUID.randomUUID() + "/sessions")); + + verify(notifier, never()).broadcast(any(), any()); + } + + @Test + @DisplayName("unsubscribing the last subscription rebroadcasts an empty roster") + void unsubscribeBroadcastsEmptyRoster() { + when(resolver.resolve(orgId, alice)) + .thenReturn(new SessionParticipant(alice, "Ana", "/api/users/" + alice + "/avatar")); + tracker.onSubscribe(subscribe("stomp-1", "sub-1", "/topic/" + SessionTopics.of(sessionId))); + + tracker.onUnsubscribe(unsubscribe("stomp-1", "sub-1")); + + ArgumentCaptor payload = ArgumentCaptor.forClass(Object.class); + verify(notifier, org.mockito.Mockito.atLeast(2)).broadcast(eq(SessionTopics.of(sessionId)), payload.capture()); + SessionPresenceMessage last = (SessionPresenceMessage) payload.getValue(); + assertThat(last.count()).isZero(); + } + + @Test + @DisplayName("disconnect rebroadcasts an empty roster") + void disconnectBroadcastsEmptyRoster() { + when(resolver.resolve(orgId, alice)) + .thenReturn(new SessionParticipant(alice, "Ana", "/api/users/" + alice + "/avatar")); + tracker.onSubscribe(subscribe("stomp-1", "sub-1", "/topic/" + SessionTopics.of(sessionId))); + + tracker.onDisconnect(disconnect("stomp-1")); + + // Last captured broadcast is the empty roster after the disconnect. + ArgumentCaptor payload = ArgumentCaptor.forClass(Object.class); + verify(notifier, org.mockito.Mockito.atLeast(2)).broadcast(eq(SessionTopics.of(sessionId)), payload.capture()); + SessionPresenceMessage last = (SessionPresenceMessage) payload.getValue(); + assertThat(last.count()).isZero(); + assertThat(last.participants()).isEmpty(); + } + + private SessionPresenceMessage captureBroadcast() { + ArgumentCaptor payload = ArgumentCaptor.forClass(Object.class); + verify(notifier).broadcast(eq(SessionTopics.of(sessionId)), payload.capture()); + return (SessionPresenceMessage) payload.getValue(); + } + + private SessionSubscribeEvent subscribe(String stompSessionId, String subscriptionId, String destination) { + StompHeaderAccessor accessor = StompHeaderAccessor.create(StompCommand.SUBSCRIBE); + accessor.setSessionId(stompSessionId); + accessor.setSubscriptionId(subscriptionId); + accessor.setDestination(destination); + accessor.setSessionAttributes(sessionAttributes()); + Principal user = new UsernamePasswordAuthenticationToken(alice.toString(), null); + accessor.setUser(user); + Message message = MessageBuilder.createMessage(new byte[0], accessor.getMessageHeaders()); + return new SessionSubscribeEvent(this, message, user); + } + + private SessionUnsubscribeEvent unsubscribe(String stompSessionId, String subscriptionId) { + StompHeaderAccessor accessor = StompHeaderAccessor.create(StompCommand.UNSUBSCRIBE); + accessor.setSessionId(stompSessionId); + accessor.setSubscriptionId(subscriptionId); + accessor.setSessionAttributes(sessionAttributes()); + Message message = MessageBuilder.createMessage(new byte[0], accessor.getMessageHeaders()); + return new SessionUnsubscribeEvent(this, message, null); + } + + private SessionDisconnectEvent disconnect(String stompSessionId) { + StompHeaderAccessor accessor = StompHeaderAccessor.create(StompCommand.DISCONNECT); + accessor.setSessionId(stompSessionId); + accessor.setSessionAttributes(sessionAttributes()); + Message message = MessageBuilder.createMessage(new byte[0], accessor.getMessageHeaders()); + return new SessionDisconnectEvent(this, message, stompSessionId, null); + } + + private Map sessionAttributes() { + Map attributes = new HashMap<>(); + attributes.put(StompAuthChannelInterceptor.ORG_ID_ATTRIBUTE, orgId.toString()); + return attributes; + } +} From 08183c6d39a65291d732edc235cbcf660f34a93a Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Wed, 8 Jul 2026 00:03:24 -0500 Subject: [PATCH 08/72] fix: stash authenticated userId in STOMP session attributes too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Principal set via accessor.setUser() on the CONNECT frame does not carry over to later frames on the same STOMP session in practice — a SUBSCRIBE frame's accessor.getUser() comes back null, verified against a real running instance. Session attributes (already used for orgId) are the WebSocketSession's own attribute map and do persist across every frame, so the verified userId is now stashed there too. --- .../StompAuthChannelInterceptor.java | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/kntro/reqsai/shared/infrastructure/web/websocket/StompAuthChannelInterceptor.java b/src/main/java/com/kntro/reqsai/shared/infrastructure/web/websocket/StompAuthChannelInterceptor.java index 9600ac63..09932bc0 100644 --- a/src/main/java/com/kntro/reqsai/shared/infrastructure/web/websocket/StompAuthChannelInterceptor.java +++ b/src/main/java/com/kntro/reqsai/shared/infrastructure/web/websocket/StompAuthChannelInterceptor.java @@ -26,15 +26,23 @@ * {@code @MessageMapping} security work). A CONNECT with no token is left anonymous; a CONNECT with an * invalid token is rejected (the verifier throws). *

- * The verified {@code orgId} (tenant) is also stashed in the STOMP session attributes under - * {@link #ORG_ID_ATTRIBUTE}: session-lifecycle listeners (e.g. discovery presence) run on the broker - * thread with no bound {@code TenantContext}, so they read the tenant from here rather than the JWT. + * The verified {@code userId} and {@code orgId} are also stashed in the STOMP session attributes + * ({@link #USER_ID_ATTRIBUTE}, {@link #ORG_ID_ATTRIBUTE}). This is deliberate, not redundant with + * {@link StompHeaderAccessor#setUser}: empirically, the {@code Principal} set on the CONNECT frame's + * accessor does not carry over to later frames on the same STOMP session (a later + * SUBSCRIBE/UNSUBSCRIBE frame's {@code accessor.getUser()} is {@code null}), whereas session + * attributes are the underlying {@code WebSocketSession}'s own attribute map and do persist across + * every frame. Session-lifecycle listeners (e.g. discovery presence) that need the caller's identity + * outside the CONNECT frame must read it from here, not from {@code accessor.getUser()}. */ @Component @RequiredArgsConstructor @Slf4j public class StompAuthChannelInterceptor implements ChannelInterceptor { + /** STOMP session-attribute key holding the authenticated user id (a {@code String}). */ + public static final String USER_ID_ATTRIBUTE = "reqsai.userId"; + /** STOMP session-attribute key holding the authenticated tenant/organization id (a {@code String}). */ public static final String ORG_ID_ATTRIBUTE = "reqsai.orgId"; @@ -52,8 +60,11 @@ public Message preSend(@NonNull Message message, @NonNull MessageChannel c token.role() != null ? List.of(new SimpleGrantedAuthority(token.role())) : List.of()); accessor.setUser(authentication); Map attributes = accessor.getSessionAttributes(); - if (attributes != null && token.orgId() != null) { - attributes.put(ORG_ID_ATTRIBUTE, token.orgId()); + if (attributes != null) { + attributes.put(USER_ID_ATTRIBUTE, token.userId()); + if (token.orgId() != null) { + attributes.put(ORG_ID_ATTRIBUTE, token.orgId()); + } } log.debug("WebSocket CONNECT authenticated for user {} (tenant {})", token.userId(), token.orgId()); From b03a7dfc7509aeedfa7afac67bd3894a325d08b7 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Wed, 8 Jul 2026 00:03:32 -0500 Subject: [PATCH 09/72] fix: read presence identity from session attributes, not Principal SessionPresenceTracker relied on accessor.getUser() to identify the subscriber, which is null on SUBSCRIBE/UNSUBSCRIBE frames (see the StompAuthChannelInterceptor fix). This is why no presence roster ever appeared, even for a session's very own recorder. Reads userId from the same session-attribute mechanism already used for orgId instead, and adds a regression test pinning this exact failure mode. Verified end-to-end against a running instance: PRESENCE_STATE now broadcasts correctly with the resolved member name. --- .../presence/SessionPresenceTracker.java | 30 ++++++++----------- .../presence/SessionPresenceTrackerTest.java | 28 +++++++++++++---- 2 files changed, 35 insertions(+), 23 deletions(-) diff --git a/src/main/java/com/kntro/reqsai/discovery/interfaces/websocket/presence/SessionPresenceTracker.java b/src/main/java/com/kntro/reqsai/discovery/interfaces/websocket/presence/SessionPresenceTracker.java index 9ed8d524..4a46609a 100644 --- a/src/main/java/com/kntro/reqsai/discovery/interfaces/websocket/presence/SessionPresenceTracker.java +++ b/src/main/java/com/kntro/reqsai/discovery/interfaces/websocket/presence/SessionPresenceTracker.java @@ -15,7 +15,6 @@ import org.springframework.web.socket.messaging.SessionSubscribeEvent; import org.springframework.web.socket.messaging.SessionUnsubscribeEvent; -import java.security.Principal; import java.time.Instant; import java.util.List; import java.util.Map; @@ -45,8 +44,9 @@ public class SessionPresenceTracker { void onSubscribe(SessionSubscribeEvent event) { StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage()); UUID sessionId = parseSessionId(accessor.getDestination()); - UUID userId = userId(accessor.getUser()); - UUID orgId = orgId(accessor.getSessionAttributes()); + Map attributes = accessor.getSessionAttributes(); + UUID userId = attributeUuid(attributes, StompAuthChannelInterceptor.USER_ID_ATTRIBUTE); + UUID orgId = attributeUuid(attributes, StompAuthChannelInterceptor.ORG_ID_ATTRIBUTE); String stompSessionId = accessor.getSessionId(); String subscriptionId = accessor.getSubscriptionId(); if (sessionId == null || userId == null || orgId == null @@ -66,7 +66,7 @@ void onUnsubscribe(SessionUnsubscribeEvent event) { if (stompSessionId == null || subscriptionId == null) { return; } - UUID orgId = orgId(accessor.getSessionAttributes()); + UUID orgId = attributeUuid(accessor.getSessionAttributes(), StompAuthChannelInterceptor.ORG_ID_ATTRIBUTE); registry.leaveSubscription(stompSessionId, subscriptionId) .ifPresent(sessionId -> broadcast(sessionId, orgId)); } @@ -78,7 +78,7 @@ void onDisconnect(SessionDisconnectEvent event) { if (stompSessionId == null) { return; } - UUID orgId = orgId(accessor.getSessionAttributes()); + UUID orgId = attributeUuid(accessor.getSessionAttributes(), StompAuthChannelInterceptor.ORG_ID_ATTRIBUTE); for (UUID sessionId : registry.disconnect(stompSessionId)) { broadcast(sessionId, orgId); } @@ -119,22 +119,16 @@ private static UUID parseSessionId(String destination) { } } - private static UUID userId(Principal principal) { - if (principal == null) { - return null; - } - try { - return UUID.fromString(principal.getName()); - } catch (IllegalArgumentException ex) { - return null; - } - } - - private static UUID orgId(Map attributes) { + /** + * Reads a UUID-valued STOMP session attribute (see {@link StompAuthChannelInterceptor}). + * Session attributes — unlike the frame's {@code Principal} — persist across every frame of a + * STOMP session, which is why identity is read from here rather than {@code accessor.getUser()}. + */ + private static UUID attributeUuid(Map attributes, String key) { if (attributes == null) { return null; } - Object value = attributes.get(StompAuthChannelInterceptor.ORG_ID_ATTRIBUTE); + Object value = attributes.get(key); if (!(value instanceof String raw)) { return null; } diff --git a/src/test/java/com/kntro/reqsai/discovery/interfaces/websocket/presence/SessionPresenceTrackerTest.java b/src/test/java/com/kntro/reqsai/discovery/interfaces/websocket/presence/SessionPresenceTrackerTest.java index ef584529..a9de69e3 100644 --- a/src/test/java/com/kntro/reqsai/discovery/interfaces/websocket/presence/SessionPresenceTrackerTest.java +++ b/src/test/java/com/kntro/reqsai/discovery/interfaces/websocket/presence/SessionPresenceTrackerTest.java @@ -16,12 +16,10 @@ import org.springframework.messaging.simp.stomp.StompCommand; import org.springframework.messaging.simp.stomp.StompHeaderAccessor; import org.springframework.messaging.support.MessageBuilder; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.web.socket.messaging.SessionDisconnectEvent; import org.springframework.web.socket.messaging.SessionSubscribeEvent; import org.springframework.web.socket.messaging.SessionUnsubscribeEvent; -import java.security.Principal; import java.util.HashMap; import java.util.Map; import java.util.UUID; @@ -77,6 +75,27 @@ void ignoresUnrelatedDestination() { verify(notifier, never()).broadcast(any(), any()); } + @Test + @DisplayName("a subscribe with no user-id session attribute is ignored, even with a Principal on the frame") + void ignoresSubscribeMissingUserIdAttribute() { + // Regression test: the STOMP Principal set on CONNECT does not carry over to later frames in + // practice (verified against a real Spring STOMP session), so the tracker must not depend on + // accessor.getUser() — only on the session-attribute identity stashed by the auth interceptor. + StompHeaderAccessor accessor = StompHeaderAccessor.create(StompCommand.SUBSCRIBE); + accessor.setSessionId("stomp-1"); + accessor.setSubscriptionId("sub-1"); + accessor.setDestination("/topic/" + SessionTopics.of(sessionId)); + Map attributesWithoutUserId = new HashMap<>(); + attributesWithoutUserId.put(StompAuthChannelInterceptor.ORG_ID_ATTRIBUTE, orgId.toString()); + accessor.setSessionAttributes(attributesWithoutUserId); + accessor.setUser(new org.springframework.security.authentication.UsernamePasswordAuthenticationToken(alice.toString(), null)); + Message message = MessageBuilder.createMessage(new byte[0], accessor.getMessageHeaders()); + + tracker.onSubscribe(new SessionSubscribeEvent(this, message, null)); + + verify(notifier, never()).broadcast(any(), any()); + } + @Test @DisplayName("unsubscribing the last subscription rebroadcasts an empty roster") void unsubscribeBroadcastsEmptyRoster() { @@ -121,10 +140,8 @@ private SessionSubscribeEvent subscribe(String stompSessionId, String subscripti accessor.setSubscriptionId(subscriptionId); accessor.setDestination(destination); accessor.setSessionAttributes(sessionAttributes()); - Principal user = new UsernamePasswordAuthenticationToken(alice.toString(), null); - accessor.setUser(user); Message message = MessageBuilder.createMessage(new byte[0], accessor.getMessageHeaders()); - return new SessionSubscribeEvent(this, message, user); + return new SessionSubscribeEvent(this, message, null); } private SessionUnsubscribeEvent unsubscribe(String stompSessionId, String subscriptionId) { @@ -146,6 +163,7 @@ private SessionDisconnectEvent disconnect(String stompSessionId) { private Map sessionAttributes() { Map attributes = new HashMap<>(); + attributes.put(StompAuthChannelInterceptor.USER_ID_ATTRIBUTE, alice.toString()); attributes.put(StompAuthChannelInterceptor.ORG_ID_ATTRIBUTE, orgId.toString()); return attributes; } From 1a2177cc48a1a3e7082161f5cc31eebad07b86fa Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Wed, 8 Jul 2026 00:04:36 -0500 Subject: [PATCH 10/72] docs: correct STOMP Principal persistence claim in REALTIME.md The doc claimed the CONNECT-bound Principal is available on later frames ("so ... per-message security work"); empirically it is not. Documents the actual mechanism (session attributes) so the next person extending @MessageMapping or per-user routing doesn't rediscover this the hard way. --- docs/REALTIME.md | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/docs/REALTIME.md b/docs/REALTIME.md index a361970f..bd076d28 100644 --- a/docs/REALTIME.md +++ b/docs/REALTIME.md @@ -73,9 +73,20 @@ client.activate(); 1. The HTTP handshake to `/ws/**` is **permitted** in `SecurityConfiguration` (no token yet). 2. The client sends `Authorization: Bearer ` as a native STOMP header on **CONNECT**. 3. `StompAuthChannelInterceptor` verifies it with the same `TokenVerifier` as the HTTP filter and binds - the user `Principal` to the session — so `/user/**` queues and per-message security work. + the user `Principal` to the CONNECT frame's accessor, and — separately — stashes `userId`/`orgId` in + the STOMP **session attributes**. 4. CONNECT with no token → anonymous; with an invalid token → rejected (the verifier throws). +> **The `Principal` does not persist past CONNECT.** Empirically (verified against a running instance), +> a later frame's `accessor.getUser()` on the same STOMP session comes back `null` — only the session +> attributes carry over to every subsequent frame. Anything that needs the caller's identity outside the +> CONNECT handler itself (a session-lifecycle listener, a future `@MessageMapping` handler) must read +> `StompAuthChannelInterceptor.USER_ID_ATTRIBUTE`/`ORG_ID_ATTRIBUTE` from `accessor.getSessionAttributes()`, +> not `accessor.getUser()`. This is why presence resolves identity this way (see below). `sendToUser` +> is expected to be unaffected — Spring resolves it via a username registry populated from the CONNECT +> frame itself, not by re-reading `accessor.getUser()` on later frames — but it has no caller in this +> codebase yet, so that has not been directly exercised. + ## Destination prefixes | Prefix | Direction | Use | @@ -97,9 +108,10 @@ subscription and **no** client→server message: - **Broadcast:** on any real roster change the tracker sends a `PRESENCE_STATE` message (`SessionPresenceMessage`: full participant snapshot + `count`) back on the same `sessions/{id}` topic. The client keeps its one subscription and switches on `type` (see the single-topic pattern below). -- **Identity:** the CONNECT interceptor stashes the tenant `orgId` in the STOMP session attributes; - the tracker resolves each `userId` to a display name via `WorkspaceModuleApi.findMemberDisplayName` - (Caffeine-cached) and a deterministic `avatarUrl` (`/api/users/{userId}/avatar`). +- **Identity:** the CONNECT interceptor stashes `userId` and `orgId` in the STOMP session attributes + (see the callout above — not the frame `Principal`); the tracker resolves each `userId` to a display + name via `WorkspaceModuleApi.findMemberDisplayName` (Caffeine-cached) and a deterministic `avatarUrl` + (`/api/users/{userId}/avatar`). > Multi-instance caveat: because the registry is per-JVM (same as the `SIMPLE` broker), a global roster > across several ECS tasks needs the shared `RELAY` broker's state — acceptable at single-instance scale. From 648351911f834fccec0a2117d13e85f358980486 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Wed, 8 Jul 2026 00:31:01 -0500 Subject: [PATCH 11/72] fix: annotate SessionPresenceMessage.type() with @JsonProperty Jackson's record serializer only emits canonical record components; type() was a plain interface-method override returning a hardcoded constant, not a component, so it was silently dropped from the JSON. The client's message.type === 'PRESENCE_STATE' switch therefore never matched even though the message itself arrived correctly over the WebSocket. Matches the existing @JsonProperty("type") idiom already used by SessionProcessingFailedMessage/SessionStoryGeneratedMessage/ SessionTranscriptSegmentMessage for the same fixed-type-constant case. Verified against a running instance: the wire payload now includes "type":"PRESENCE_STATE". --- .../notification/messages/SessionPresenceMessage.java | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/main/java/com/kntro/reqsai/discovery/interfaces/notification/messages/SessionPresenceMessage.java b/src/main/java/com/kntro/reqsai/discovery/interfaces/notification/messages/SessionPresenceMessage.java index f64d0be7..f7160888 100644 --- a/src/main/java/com/kntro/reqsai/discovery/interfaces/notification/messages/SessionPresenceMessage.java +++ b/src/main/java/com/kntro/reqsai/discovery/interfaces/notification/messages/SessionPresenceMessage.java @@ -1,5 +1,6 @@ package com.kntro.reqsai.discovery.interfaces.notification.messages; +import com.fasterxml.jackson.annotation.JsonProperty; import com.kntro.reqsai.discovery.interfaces.notification.SessionEventType; import java.time.Instant; @@ -26,7 +27,16 @@ public static SessionPresenceMessage of(UUID sessionId, List return new SessionPresenceMessage(sessionId, List.copyOf(participants), participants.size(), occurredAt); } + /** + * {@code type} is a fixed constant, not a canonical record component — the same pattern used by + * {@code SessionProcessingFailedMessage}/{@code SessionStoryGeneratedMessage}/ + * {@code SessionTranscriptSegmentMessage}. The explicit {@code @JsonProperty} is required: Jackson's + * record serializer only emits canonical components, so without it this override is silently + * dropped from the JSON and the client never sees a discriminator to switch on (the bug this + * annotation fixes — verified missing from the wire payload). + */ @Override + @JsonProperty("type") public SessionEventType type() { return SessionEventType.PRESENCE_STATE; } From e5ed2de0c5332d420e1cf5637a66c17d6d47077d Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Wed, 8 Jul 2026 00:31:09 -0500 Subject: [PATCH 12/72] test: guard every SessionRealtimeMessage's JSON type field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing presence tests all inspected the Java object directly via Mockito captors, so none of them would have caught the missing "type" JSON field — that requires a real Jackson pass over the actual wire output. Adds a parameterized test asserting every SessionRealtimeMessage implementation serializes a "type" matching type(), plus a reflection check that SessionPresenceMessage.type() stays @JsonProperty-annotated. Confirmed both fail against the pre-fix code (2 failures), pass after. --- ...ssionRealtimeMessageSerializationTest.java | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 src/test/java/com/kntro/reqsai/discovery/interfaces/notification/messages/SessionRealtimeMessageSerializationTest.java diff --git a/src/test/java/com/kntro/reqsai/discovery/interfaces/notification/messages/SessionRealtimeMessageSerializationTest.java b/src/test/java/com/kntro/reqsai/discovery/interfaces/notification/messages/SessionRealtimeMessageSerializationTest.java new file mode 100644 index 00000000..6559394c --- /dev/null +++ b/src/test/java/com/kntro/reqsai/discovery/interfaces/notification/messages/SessionRealtimeMessageSerializationTest.java @@ -0,0 +1,87 @@ +package com.kntro.reqsai.discovery.interfaces.notification.messages; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.kntro.reqsai.discovery.domain.model.Priority; +import com.kntro.reqsai.discovery.domain.model.SessionStatus; +import com.kntro.reqsai.discovery.domain.model.SuggestionStatus; +import com.kntro.reqsai.discovery.domain.model.SuggestionType; +import com.kntro.reqsai.discovery.interfaces.notification.SessionEventType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Guards the wire contract every {@link SessionRealtimeMessage} implementation must honor: the + * serialized JSON must carry a {@code "type"} field matching {@link SessionRealtimeMessage#type()}. + *

+ * This is not redundant with the per-message unit tests: those inspect the Java object directly and + * would pass even if {@code type} were silently dropped from the JSON. That exact bug shipped once — + * {@code SessionPresenceMessage} overrode {@code type()} without a canonical record component or a + * {@code @JsonProperty("type")} annotation, so Jackson's record serializer (which only emits canonical + * components) omitted it entirely. The client's {@code message.type === 'PRESENCE_STATE'} switch then + * silently never matched, even though the message otherwise arrived correctly. Only a real + * {@link ObjectMapper} pass over the actual JSON output can catch this class of bug. + */ +class SessionRealtimeMessageSerializationTest { + + private final ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule()); + + @ParameterizedTest(name = "{0} serializes type={1}") + @MethodSource("messages") + @DisplayName("every SessionRealtimeMessage serializes a matching \"type\" field") + void serializesTypeField(SessionRealtimeMessage message, SessionEventType expectedType) throws Exception { + String json = mapper.writeValueAsString(message); + + assertThat(json).contains("\"type\":\"" + expectedType.name() + "\""); + var node = mapper.readTree(json); + assertThat(node.get("type").asText()).isEqualTo(expectedType.name()); + } + + static Stream messages() { + UUID sessionId = UUID.randomUUID(); + Instant now = Instant.now(); + return Stream.of( + org.junit.jupiter.params.provider.Arguments.of( + SessionPresenceMessage.of(sessionId, List.of(), now), SessionEventType.PRESENCE_STATE), + org.junit.jupiter.params.provider.Arguments.of( + SessionStatusChangedMessage.of(sessionId, SessionEventType.RECORDING_STARTED, now), + SessionEventType.RECORDING_STARTED), + org.junit.jupiter.params.provider.Arguments.of( + new SessionProcessingFailedMessage(sessionId, "boom", now), SessionEventType.FAILED), + org.junit.jupiter.params.provider.Arguments.of( + new SessionStoryGeneratedMessage(sessionId, UUID.randomUUID(), "Title", "role", "action", + "benefit", Priority.MEDIUM, null, now), + SessionEventType.STORY_GENERATED), + org.junit.jupiter.params.provider.Arguments.of( + new SessionTranscriptSegmentMessage(sessionId, 0, null, "text", 0L, 100L, true, now), + SessionEventType.TRANSCRIPT_SEGMENT), + org.junit.jupiter.params.provider.Arguments.of( + new SessionSuggestionMessage(sessionId, UUID.randomUUID(), SessionEventType.SUGGESTION_GENERATED, + SuggestionType.NEW_STORY, SuggestionStatus.PENDING, null, null, null, null, null, null, + null, null, null, List.of(), null, now), + SessionEventType.SUGGESTION_GENERATED), + org.junit.jupiter.params.provider.Arguments.of( + new SessionLifecycleMessage(sessionId, UUID.randomUUID(), SessionEventType.SESSION_CREATED, + SessionStatus.DRAFT, "Title", "es-PE", null, now), + SessionEventType.SESSION_CREATED) + ); + } + + @Test + @DisplayName("regression: SessionPresenceMessage.type() carries the required @JsonProperty (else Jackson drops it)") + void presenceMessageTypeAccessorIsAnnotated() throws Exception { + var method = SessionPresenceMessage.class.getMethod("type"); + assertThat(method.isAnnotationPresent(com.fasterxml.jackson.annotation.JsonProperty.class)) + .as("type() must be @JsonProperty(\"type\") annotated since it is not a canonical record component") + .isTrue(); + } +} From b75d24f4df5aa84f35b39bac245fe2de1e0bc013 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Thu, 9 Jul 2026 21:28:15 -0500 Subject: [PATCH 13/72] fix: filter realtime integration tests by expected event type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI's "Integration tests" job was failing: RealtimeNotificationIntegrationTest and SuggestionBroadcastIntegrationTest each subscribe to a session topic and assume the first frame received is the one they triggered. Since presence now auto-broadcasts a PRESENCE_STATE message the instant any client subscribes to that same topic, that broadcast could land first and get misdeserialized into the test's expected type (its JSON still decodes cleanly — extra/missing fields are just ignored), racing the real message. Each subscribe() helper now filters frames by the expected SessionEventType before enqueuing, which is the correct fix given this codebase's existing single-topic/multiple-message-kinds design (client already switches on type) rather than a workaround. --- .../RealtimeNotificationIntegrationTest.java | 25 +++++++++++++++---- .../SuggestionBroadcastIntegrationTest.java | 19 +++++++++++--- 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/src/test/java/com/kntro/reqsai/discovery/application/notification/RealtimeNotificationIntegrationTest.java b/src/test/java/com/kntro/reqsai/discovery/application/notification/RealtimeNotificationIntegrationTest.java index 699c17da..dc2c5265 100644 --- a/src/test/java/com/kntro/reqsai/discovery/application/notification/RealtimeNotificationIntegrationTest.java +++ b/src/test/java/com/kntro/reqsai/discovery/application/notification/RealtimeNotificationIntegrationTest.java @@ -6,6 +6,7 @@ import com.kntro.reqsai.discovery.domain.model.Priority; import com.kntro.reqsai.discovery.interfaces.notification.SessionEventType; import com.kntro.reqsai.discovery.interfaces.notification.messages.SessionProcessingFailedMessage; +import com.kntro.reqsai.discovery.interfaces.notification.messages.SessionRealtimeMessage; import com.kntro.reqsai.discovery.interfaces.notification.messages.SessionStatusChangedMessage; import com.kntro.reqsai.discovery.interfaces.notification.messages.SessionStoryGeneratedMessage; import com.kntro.reqsai.testsupport.AbstractIntegrationTest; @@ -87,7 +88,8 @@ void tearDown() { void should_deliver_status_change() throws Exception { // Arrange UUID sessionId = UUID.randomUUID(); - var received = subscribe(connectAuthenticated(), sessionId, SessionStatusChangedMessage.class); + var received = subscribe(connectAuthenticated(), sessionId, SessionStatusChangedMessage.class, + SessionEventType.RECORDING_STARTED); // Act & Assert SessionStatusChangedMessage msg = awaitFirst(received, @@ -101,7 +103,8 @@ void should_deliver_status_change() throws Exception { void should_deliver_failure_reason() throws Exception { // Arrange UUID sessionId = UUID.randomUUID(); - var received = subscribe(connectAuthenticated(), sessionId, SessionProcessingFailedMessage.class); + var received = subscribe(connectAuthenticated(), sessionId, SessionProcessingFailedMessage.class, + SessionEventType.FAILED); // Act & Assert SessionProcessingFailedMessage msg = awaitFirst(received, @@ -116,7 +119,8 @@ void should_deliver_story_generated() throws Exception { // Arrange UUID sessionId = UUID.randomUUID(); UUID storyId = UUID.randomUUID(); - var received = subscribe(connectAuthenticated(), sessionId, SessionStoryGeneratedMessage.class); + var received = subscribe(connectAuthenticated(), sessionId, SessionStoryGeneratedMessage.class, + SessionEventType.STORY_GENERATED); // Act & Assert SessionStoryGeneratedMessage msg = awaitFirst(received, @@ -161,7 +165,15 @@ private StompSession connect(String authorization) throws Exception { .get(5, TimeUnit.SECONDS); } - private BlockingQueue subscribe(StompSession session, UUID sessionId, Class payloadType) { + /** + * Subscribes and filters incoming frames to {@code expectedType} before enqueuing. A subscriber + * to a session topic can now also receive an automatic {@code PRESENCE_STATE} broadcast (see + * {@code SessionPresenceTracker}) the instant it subscribes — the JSON still decodes cleanly into + * whatever {@code payloadType} the caller asked for (a record's extra unmapped fields are just + * ignored), so without this filter the spurious presence frame would race the real one under test. + */ + private BlockingQueue subscribe( + StompSession session, UUID sessionId, Class payloadType, SessionEventType expectedType) { BlockingQueue queue = new LinkedBlockingQueue<>(); session.subscribe("/topic/" + SessionTopics.of(sessionId), new StompFrameHandler() { @Override @@ -172,7 +184,10 @@ public Type getPayloadType(@NonNull StompHeaders headers) { @Override public void handleFrame(@NonNull StompHeaders headers, Object payload) { - queue.add(payloadType.cast(payload)); + T message = payloadType.cast(payload); + if (message.type() == expectedType) { + queue.add(message); + } } }); return queue; diff --git a/src/test/java/com/kntro/reqsai/discovery/application/notification/SuggestionBroadcastIntegrationTest.java b/src/test/java/com/kntro/reqsai/discovery/application/notification/SuggestionBroadcastIntegrationTest.java index 1caafecb..b94a6f84 100644 --- a/src/test/java/com/kntro/reqsai/discovery/application/notification/SuggestionBroadcastIntegrationTest.java +++ b/src/test/java/com/kntro/reqsai/discovery/application/notification/SuggestionBroadcastIntegrationTest.java @@ -4,6 +4,7 @@ import com.kntro.reqsai.discovery.domain.model.Priority; import com.kntro.reqsai.discovery.domain.model.SuggestionType; import com.kntro.reqsai.discovery.interfaces.notification.SessionEventType; +import com.kntro.reqsai.discovery.interfaces.notification.messages.SessionRealtimeMessage; import com.kntro.reqsai.discovery.interfaces.notification.messages.SessionSuggestionMessage; import com.kntro.reqsai.testsupport.AbstractIntegrationTest; import com.kntro.reqsai.testsupport.TestJwtFactory; @@ -81,7 +82,8 @@ void should_broadcast_suggestion_generated() throws Exception { UUID projectId = UUID.randomUUID(); BlockingQueue received = - subscribe(connectAuthenticated(), sessionId, SessionSuggestionMessage.class); + subscribe(connectAuthenticated(), sessionId, SessionSuggestionMessage.class, + SessionEventType.SUGGESTION_GENERATED); SuggestionCreatedEvent event = new SuggestionCreatedEvent( suggestionId, sessionId, projectId, SuggestionType.NEW_STORY, @@ -111,7 +113,15 @@ private StompSession connectAuthenticated() throws Exception { .get(5, TimeUnit.SECONDS); } - private BlockingQueue subscribe(StompSession session, UUID sessionId, Class payloadType) { + /** + * Subscribes and filters incoming frames to {@code expectedType} before enqueuing. A subscriber + * to a session topic can now also receive an automatic {@code PRESENCE_STATE} broadcast (see + * {@code SessionPresenceTracker}) the instant it subscribes — the JSON still decodes cleanly into + * whatever {@code payloadType} the caller asked for (a record's extra unmapped fields are just + * ignored), so without this filter the spurious presence frame would race the real one under test. + */ + private BlockingQueue subscribe( + StompSession session, UUID sessionId, Class payloadType, SessionEventType expectedType) { BlockingQueue queue = new LinkedBlockingQueue<>(); session.subscribe("/topic/" + SessionTopics.of(sessionId), new StompFrameHandler() { @Override @@ -122,7 +132,10 @@ public Type getPayloadType(@NonNull StompHeaders headers) { @Override public void handleFrame(@NonNull StompHeaders headers, Object payload) { - queue.add(payloadType.cast(payload)); + T message = payloadType.cast(payload); + if (message.type() == expectedType) { + queue.add(message); + } } }); return queue; From 94515bcc60c538d01a3adfc61ea9db5df10b268a Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Thu, 9 Jul 2026 21:17:09 -0500 Subject: [PATCH 14/72] fix: point APP_URL/FRONTEND_URL/CORS at the custom domain (tamci.app) Now that api.tamci.app and app.tamci.app have real ACM certs wired into the ALB and CloudFront (reqsai-infra), the task definition should reference those instead of the raw ELB/CloudFront default hostnames. --- ecs/task-definition.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ecs/task-definition.json b/ecs/task-definition.json index 98911a51..f8d75a9f 100644 --- a/ecs/task-definition.json +++ b/ecs/task-definition.json @@ -15,7 +15,7 @@ "environment": [ { "name": "CORS_ALLOWED_ORIGINS", - "value": "https://d29o19vcsmcdsd.cloudfront.net" + "value": "https://app.tamci.app" }, { "name": "SPRINGDOC_API_DOCS_ENABLED", @@ -51,7 +51,7 @@ }, { "name": "FRONTEND_URL", - "value": "https://d29o19vcsmcdsd.cloudfront.net" + "value": "https://app.tamci.app" }, { "name": "MAIL_PORT", @@ -83,7 +83,7 @@ }, { "name": "APP_URL", - "value": "http://reqsai-production-api-121983331.us-east-1.elb.amazonaws.com" + "value": "https://api.tamci.app" }, { "name": "GENERATION_PROVIDER", From 51471d7f24b8ffc1438f30896f1039df78c140d5 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Mon, 6 Jul 2026 18:59:38 -0500 Subject: [PATCH 15/72] docs(adr): add ADR-0022 for extensible third-party integrations (jira) --- .../adr/0023-third-party-integrations-jira.md | 148 ++++++++++++++++++ docs/adr/README.md | 1 + 2 files changed, 149 insertions(+) create mode 100644 docs/adr/0023-third-party-integrations-jira.md diff --git a/docs/adr/0023-third-party-integrations-jira.md b/docs/adr/0023-third-party-integrations-jira.md new file mode 100644 index 00000000..8edafd18 --- /dev/null +++ b/docs/adr/0023-third-party-integrations-jira.md @@ -0,0 +1,148 @@ +# 0023. Extensible third-party integrations, first provider Jira Cloud + +- Status: Accepted +- Date: 2026-07-06 +- Deciders: Kntro-Soft team + +## Context + +Teams that run discovery in Reqs-AI keep their delivery backlog in an external tracker. The first and +most requested target is **Jira Cloud**: an analyst approves user stories in Reqs-AI and wants to push +them into a Jira project as issues without re-typing them. The `UserStory` review lifecycle already +has an `EXPORTED` terminal state described as _"Pushed to an external tracker (e.g. Jira)"_, so the +domain anticipated this. + +We want the design to generalize beyond Jira (Azure DevOps, Linear, GitHub Issues, …) rather than +bolt a one-off Jira client onto an existing context, and we want to keep the credential and the +push concern out of `discovery` and `workspace` — they are a distinct capability with their own +lifecycle, error surface and RBAC. + +Forces: + +- **Where do credentials live vs. where does a push target live?** A Jira site + API token is an + organization-wide asset (billed once, administered by an org admin). A Jira *project key* and + *issue type* are per Reqs-AI-project routing decisions any project writer makes. These have + different owners, different RBAC and different lifecycles. +- **Auth will evolve.** Jira Cloud supports both an **API token / basic auth** (simplest, works today, + no app registration) and **OAuth 2.0 (3LO)**. We ship the token flow now but must not paint + ourselves into a corner that blocks OAuth later. +- **Secrets at rest.** A Jira API token is a bearer credential. It must be encrypted in the tenant + database, never logged, and never returned by any endpoint. +- **Module boundaries must hold.** The backend is a Spring Modulith modular monolith (ADR-0002) with + schema-per-tenant multitenancy (ADR-0003); ArchUnit + `verifyModularity` (ADR-0019) enforce that + cross-module talk only happens through named interfaces. Reading user stories to push them must go + through a published discovery interface, not into discovery internals. +- **Identity/auth is out of scope.** This feature must not touch IAM's account/authn model. It only + adds project-scoped RBAC permissions to the existing workspace `Permission` catalog. + +## Decision + +### A new `integrations` bounded context + +Introduce `com.kntro.reqsai.integrations` as its own Spring Modulith application module +(`@ApplicationModule(allowedDependencies = {"shared", "workspace::api", "discovery::api"})`), with the +usual hexagonal layers (`domain`, `application`, `infrastructure`, `interfaces`) plus an `api` +named-interface package reserved for future cross-module exposure. It depends on `workspace::api` for +org/project authorization context and on a new `discovery::api` named interface for reading the +stories it pushes. + +### Org-level connection, project-level target (the split) + +Two aggregates, two scopes: + +- **`IntegrationConnection` (organization-scoped)** — one row in `integration_connections` per + `(organization_id, provider)`. Holds the provider (`JIRA`), the Jira `site_url`, the account + `email`, the **encrypted** API token (`secret_ciphertext BYTEA`), a `status` + (`CONNECTED`/`DISCONNECTED`) and `last_verified_at`. A **partial unique index** + (`WHERE status <> 'DISCONNECTED'`) enforces **at most one active connection per org per provider**. + Managed by org admins. +- **`ProjectIntegrationTarget` (project-scoped)** — one row in `project_integration_targets` per + project (see the uniqueness decision below). References a `connection_id`, plus the Jira + `jira_project_key` and `issue_type_name` chosen for that Reqs-AI project. Managed by project + writers. + +This mirrors the real ownership: credentials are administered once at the top; routing is decided +per project by the people who own that project. + +**Uniqueness choice — one target per project.** `project_integration_targets` is uniquely indexed on +`project_id` alone (`uq_project_integration_targets_project`), *not* on +`(project_id, connection_id)`. A Reqs-AI project pushes to exactly one Jira destination at a time; the +`PUT .../target` endpoint is an upsert that replaces the single target. This keeps the push path +unambiguous (no "which target?" question) and matches the locked REST contract, which exposes a +singular `/integration/jira/target` resource. Re-pointing a project at a different connection or Jira +project is a `PUT` overwrite. + +### Provider/adapter pattern (`IntegrationProvider` port + `JiraProvider`) + +The push/verify capability is expressed as an `IntegrationProvider` port in the application layer. +`JiraProvider` is the first (and currently only) implementation; it delegates the raw HTTP to a +`JiraClient` RestClient adapter in `infrastructure/jira`. Adding Azure DevOps later means adding an +`AzureDevOpsProvider` selected by the connection's `provider` value — no change to the handlers, the +endpoints or the aggregates. The `JiraClient` mirrors the existing `AssemblyAiAdapter` RestClient +style (per-call `RestClient`, typed response records, status→exception mapping). + +### API token now, OAuth 2.0 (3LO) later + +Authentication today is Jira **basic auth with an API token**: +`Authorization: Basic base64(email:token)`, base URL `https://{site}/rest/api/3/...`. The credential +abstraction (`IntegrationConnection` carrying an encrypted secret + the `IntegrationProvider` seam) +is deliberately auth-mechanism-agnostic: adding OAuth 2.0 (3LO) later means storing an OAuth +refresh/access token in the same encrypted secret column (or a sibling column), adding a +`credentialType` discriminator, and having `JiraProvider` build an `Authorization: Bearer` header +instead of `Basic` — the endpoints, RBAC, target model and push flow are unchanged. No OAuth code +ships now; the seam is what ships. + +### Encryption at rest (AES-256-GCM) + +There is no existing encryption utility, so we add one: a JPA `AttributeConverter` +(`EncryptedStringConverter`) backed by an `AesGcmCipher`. It encrypts the token with **AES-256-GCM**, +a random **12-byte IV per value prepended to the ciphertext** (`IV || ciphertext+tag`), keyed from +`INTEGRATIONS_ENCRYPTION_KEY` (base64-encoded 32 bytes) bound via `application.yml` +`${INTEGRATIONS_ENCRYPTION_KEY:}`. For local/dev and tests a documented default 32-byte test key is +provided through `application-test.yml` so the suite runs without a `.env`. The token is decrypted +only inside `JiraProvider` when building the auth header; it is never logged and never leaves the +backend in any response. Cipher failures raise `INTEGRATION_ENCRYPTION_ERROR` (500). + +### Reading discovery stories through a published interface + +Integrations needs the story's title/role/action/benefit/priority/story-points and its Given/When/Then +acceptance criteria to build the Jira issue. Discovery exposes a new **`discovery::api`** named +interface with a `DiscoveryStoryReadPort` returning value-only `StoryView` / `AcceptanceCriterionView` +records (no JPA entities cross the boundary), implemented inside discovery over its existing +`UserStoryRepository`. Integrations consumes only that interface; `verifyModularity` and the ArchUnit +fitness functions stay green. + +### RBAC — new project permissions only + +The workspace `Permission` catalog gains `INTEGRATION_READ`, `INTEGRATION_WRITE`, `INTEGRATION_DELETE` +and `INTEGRATION_SYNC`, wired into the default role permission sets exactly like the existing +resources. **Project** endpoints (target read/write/delete, story push) are gated with +`@authz.projectPermission(...)`. **Org connection** endpoints are gated with the existing org-admin +path (`@authz.orgOwnerOrAdmin(#orgId, authentication)`) — administering an org-wide credential is an +org-admin action, not a project permission. IAM identity/authn is untouched. + +### Error surface + +- Domain: `IntegrationsError` — `INTEGRATION_CONNECTION_NOT_FOUND` (404), + `INTEGRATION_ALREADY_CONNECTED` (409), `INTEGRATION_TARGET_NOT_CONFIGURED` (409), + `JIRA_PROJECT_NOT_FOUND` (404). +- Infrastructure: `IntegrationsInfrastructureError` — `JIRA_AUTH_FAILED` (401), + `JIRA_UNREACHABLE` (502), `JIRA_PUSH_FAILED` (502), `INTEGRATION_ENCRYPTION_ERROR` (500). + +Both are `ErrorCatalog` enums auto-mapped by the shared `GlobalExceptionHandler`; infrastructure +errors never leak the token or the internal cause to the client. + +## Consequences + +- Positive: credentials and routing sit at their natural owners; the provider seam and the + auth-mechanism-agnostic credential make Azure DevOps / OAuth additive; the token is encrypted at + rest and never exposed; discovery is read through a boundary-checked interface; the existing + `EXPORTED` story state is finally reachable. +- Trade-off: one active connection per org per provider and one target per project are intentional + simplifications for the first release; multi-connection / multi-target routing can be revisited by + relaxing the two unique indexes without a shape change to the endpoints. +- Trade-off: a symmetric AES-GCM key in config means key rotation is a manual re-encrypt for now; a + KMS-backed key can replace the converter's key source later without touching the model. +- The push-all endpoint captures per-story failures and continues the batch, so one bad story never + aborts the export of the rest. +``` diff --git a/docs/adr/README.md b/docs/adr/README.md index 24cc142d..62f500d5 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -32,6 +32,7 @@ one (and update the old one's status). | [0020](./0020-global-search-postgres-trigram.md) | Global search with Postgres trigram lexical matching | Accepted | | [0021](./0021-organization-invitations.md) | Organization invitations with tokenized email trust | Accepted | | [0022](./0022-flyway-timestamp-based-migration-versions.md) | Timestamp-based Flyway migration versions | Accepted | +| [0023](./0023-third-party-integrations-jira.md) | Extensible third-party integrations, first Jira Cloud | Accepted | ## Template From f533dea2cf208c224ff9fa932a83b00af8ad4e61 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Mon, 6 Jul 2026 19:00:01 -0500 Subject: [PATCH 16/72] feat(integrations): add V21 tenant migration for connections and project targets --- .../tenant/V21__integration_connections.sql | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 src/main/resources/db/migration/tenant/V21__integration_connections.sql diff --git a/src/main/resources/db/migration/tenant/V21__integration_connections.sql b/src/main/resources/db/migration/tenant/V21__integration_connections.sql new file mode 100644 index 00000000..f6e30097 --- /dev/null +++ b/src/main/resources/db/migration/tenant/V21__integration_connections.sql @@ -0,0 +1,42 @@ +-- Third-party integrations (ADR-0022). +-- Connections are ORG-scoped (credentials, encrypted); targets are PROJECT-scoped (push routing). + +CREATE TABLE integration_connections ( + id UUID NOT NULL PRIMARY KEY, + organization_id UUID NOT NULL, + provider VARCHAR(32) NOT NULL, + site_url VARCHAR(500) NOT NULL, + email VARCHAR(320) NOT NULL, + secret_ciphertext BYTEA NOT NULL, + status VARCHAR(32) NOT NULL, + last_verified_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_by UUID, + updated_by UUID +); + +CREATE INDEX idx_integration_connections_org ON integration_connections (organization_id); + +-- At most one active (non-disconnected) connection per org per provider. +CREATE UNIQUE INDEX uq_integration_connections_active_org_provider + ON integration_connections (organization_id, provider) + WHERE status <> 'DISCONNECTED'; + +CREATE TABLE project_integration_targets ( + id UUID NOT NULL PRIMARY KEY, + project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + connection_id UUID NOT NULL REFERENCES integration_connections(id) ON DELETE CASCADE, + jira_project_key VARCHAR(100) NOT NULL, + issue_type_name VARCHAR(100) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_by UUID, + updated_by UUID +); + +-- One integration target per project (the PUT .../target endpoint upserts this single row). +CREATE UNIQUE INDEX uq_project_integration_targets_project + ON project_integration_targets (project_id); + +CREATE INDEX idx_project_integration_targets_connection ON project_integration_targets (connection_id); From 562863228df71d7e95a3250f8d594a495f986f42 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Mon, 6 Jul 2026 19:02:01 -0500 Subject: [PATCH 17/72] feat(discovery): expose story read port via discovery::api and add integration rbac permissions --- .../api/AcceptanceCriterionView.java | 14 +++++ .../discovery/api/DiscoveryStoryReadPort.java | 26 ++++++++ .../kntro/reqsai/discovery/api/StoryView.java | 26 ++++++++ .../reqsai/discovery/api/package-info.java | 14 +++++ .../service/DiscoveryStoryReadPortImpl.java | 62 +++++++++++++++++++ .../workspace/domain/model/Permission.java | 9 ++- 6 files changed, 150 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/kntro/reqsai/discovery/api/AcceptanceCriterionView.java create mode 100644 src/main/java/com/kntro/reqsai/discovery/api/DiscoveryStoryReadPort.java create mode 100644 src/main/java/com/kntro/reqsai/discovery/api/StoryView.java create mode 100644 src/main/java/com/kntro/reqsai/discovery/api/package-info.java create mode 100644 src/main/java/com/kntro/reqsai/discovery/application/service/DiscoveryStoryReadPortImpl.java diff --git a/src/main/java/com/kntro/reqsai/discovery/api/AcceptanceCriterionView.java b/src/main/java/com/kntro/reqsai/discovery/api/AcceptanceCriterionView.java new file mode 100644 index 00000000..c4579097 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/discovery/api/AcceptanceCriterionView.java @@ -0,0 +1,14 @@ +package com.kntro.reqsai.discovery.api; + +import org.jspecify.annotations.Nullable; + +/** + * Read-only projection of a user story's acceptance criterion exposed by Discovery via + * {@link DiscoveryStoryReadPort}. Given/When/Then plus an optional scenario label. No JPA entity. + */ +public record AcceptanceCriterionView( + @Nullable String scenario, + String given, + String when, + String then +) {} diff --git a/src/main/java/com/kntro/reqsai/discovery/api/DiscoveryStoryReadPort.java b/src/main/java/com/kntro/reqsai/discovery/api/DiscoveryStoryReadPort.java new file mode 100644 index 00000000..6636d837 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/discovery/api/DiscoveryStoryReadPort.java @@ -0,0 +1,26 @@ +package com.kntro.reqsai.discovery.api; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +/** + * Public ACL interface of the Discovery bounded context for reading user stories, accessible to other + * Spring Modulith modules. Returns plain-value {@link StoryView} snapshots — no JPA entities escape + * this boundary. All reads are tenant-scoped (schema resolved from the JWT {@code orgId}). + * + *

Implementations are package-private and registered as Spring beans; callers depend only on this + * interface (anti-corruption layer). Consumed by {@code integrations} to render stories into external + * tracker issues. + */ +public interface DiscoveryStoryReadPort { + + /** + * Returns a read-only projection of one story scoped to the given project, or + * {@link Optional#empty()} when the story does not exist or belongs to a different project/tenant. + */ + Optional findStory(UUID projectId, UUID storyId); + + /** Returns read-only projections of every story in the project (empty when the project has none). */ + List listStories(UUID projectId); +} diff --git a/src/main/java/com/kntro/reqsai/discovery/api/StoryView.java b/src/main/java/com/kntro/reqsai/discovery/api/StoryView.java new file mode 100644 index 00000000..b56f035a --- /dev/null +++ b/src/main/java/com/kntro/reqsai/discovery/api/StoryView.java @@ -0,0 +1,26 @@ +package com.kntro.reqsai.discovery.api; + +import org.jspecify.annotations.Nullable; + +import java.util.List; +import java.util.UUID; + +/** + * Read-only projection of a {@code UserStory} exposed by Discovery via {@link DiscoveryStoryReadPort}. + * Carries only the text fields another module needs to render the story into an external tracker issue + * (title, role/action/benefit, priority, story points and the Given/When/Then acceptance criteria). + * + *

{@code priority} is the {@code Priority} enum name; no JPA entities, no embeddings cross this + * boundary. + */ +public record StoryView( + UUID storyId, + UUID projectId, + String title, + String role, + String action, + String benefit, + String priority, + @Nullable Integer storyPoints, + List acceptanceCriteria +) {} diff --git a/src/main/java/com/kntro/reqsai/discovery/api/package-info.java b/src/main/java/com/kntro/reqsai/discovery/api/package-info.java new file mode 100644 index 00000000..1e8c8440 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/discovery/api/package-info.java @@ -0,0 +1,14 @@ +/** + * Named interface of the Discovery module — the only types other modules may import. + *

+ * Exposes {@link com.kntro.reqsai.discovery.api.DiscoveryStoryReadPort} and its read-only value + * records ({@link com.kntro.reqsai.discovery.api.StoryView}, + * {@link com.kntro.reqsai.discovery.api.AcceptanceCriterionView}) so other modules (e.g. + * {@code integrations}) can read user stories to push them to external trackers without reaching into + * Discovery internals. No JPA entities cross this boundary. + *

+ * Declare {@code allowedDependencies = "discovery::api"} in the consuming module's + * {@code @ApplicationModule} annotation to make Spring Modulith enforce the boundary. + */ +@org.springframework.modulith.NamedInterface("api") +package com.kntro.reqsai.discovery.api; diff --git a/src/main/java/com/kntro/reqsai/discovery/application/service/DiscoveryStoryReadPortImpl.java b/src/main/java/com/kntro/reqsai/discovery/application/service/DiscoveryStoryReadPortImpl.java new file mode 100644 index 00000000..742f8d87 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/discovery/application/service/DiscoveryStoryReadPortImpl.java @@ -0,0 +1,62 @@ +package com.kntro.reqsai.discovery.application.service; + +import com.kntro.reqsai.discovery.api.AcceptanceCriterionView; +import com.kntro.reqsai.discovery.api.DiscoveryStoryReadPort; +import com.kntro.reqsai.discovery.api.StoryView; +import com.kntro.reqsai.discovery.application.port.UserStoryRepository; +import com.kntro.reqsai.discovery.domain.model.AcceptanceCriterion; +import com.kntro.reqsai.discovery.domain.model.UserStory; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +/** + * Package-private cross-context implementation of {@link DiscoveryStoryReadPort}. Reads {@link UserStory} + * aggregates through the existing {@link UserStoryRepository} and maps them to boundary value records, + * so no JPA entity crosses the module boundary. + */ +@Component +@RequiredArgsConstructor +class DiscoveryStoryReadPortImpl implements DiscoveryStoryReadPort { + + private final UserStoryRepository stories; + + @Override + @Transactional(readOnly = true) + public Optional findStory(UUID projectId, UUID storyId) { + return stories.findByIdAndProjectId(storyId, projectId).map(DiscoveryStoryReadPortImpl::toView); + } + + @Override + @Transactional(readOnly = true) + public List listStories(UUID projectId) { + return stories.findAllByProjectId(projectId, Pageable.unpaged()) + .map(DiscoveryStoryReadPortImpl::toView) + .getContent(); + } + + private static StoryView toView(UserStory story) { + List criteria = story.getAcceptanceCriteria().stream() + .map(DiscoveryStoryReadPortImpl::toView) + .toList(); + return new StoryView( + story.getId(), + story.getProjectId(), + story.getTitle(), + story.getRole(), + story.getAction(), + story.getBenefit(), + story.getPriority().name(), + story.getStoryPoints(), + criteria); + } + + private static AcceptanceCriterionView toView(AcceptanceCriterion c) { + return new AcceptanceCriterionView(c.getScenario(), c.getGiven(), c.getWhen(), c.getThen()); + } +} diff --git a/src/main/java/com/kntro/reqsai/workspace/domain/model/Permission.java b/src/main/java/com/kntro/reqsai/workspace/domain/model/Permission.java index 5b70d4a5..34922730 100644 --- a/src/main/java/com/kntro/reqsai/workspace/domain/model/Permission.java +++ b/src/main/java/com/kntro/reqsai/workspace/domain/model/Permission.java @@ -48,5 +48,12 @@ public enum Permission { // User stories (backlog) STORY_READ, - STORY_WRITE + STORY_WRITE, + + // Third-party integrations (e.g. Jira). Org-level connection administration is gated by the + // org owner/admin check; these project-scoped permissions gate the per-project target + push. + INTEGRATION_READ, + INTEGRATION_WRITE, + INTEGRATION_DELETE, + INTEGRATION_SYNC } From c333985c81c5927c71c090878d39ae9951da4314 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Mon, 6 Jul 2026 19:23:39 -0500 Subject: [PATCH 18/72] feat(integrations): add module + aes-256-gcm secret encryption at rest --- .../infrastructure/crypto/AesGcmCipher.java | 78 +++++++++++++++++++ .../IntegrationsCryptoConfiguration.java | 43 ++++++++++ .../IntegrationsInfrastructureError.java | 33 ++++++++ .../IntegrationsInfrastructureExceptions.java | 36 +++++++++ .../converters/EncryptedStringConverter.java | 53 +++++++++++++ .../reqsai/integrations/package-info.java | 15 ++++ src/main/resources/application-dev.yml | 6 ++ src/main/resources/application.yml | 4 + src/test/resources/application-test.yml | 4 + 9 files changed, 272 insertions(+) create mode 100644 src/main/java/com/kntro/reqsai/integrations/infrastructure/crypto/AesGcmCipher.java create mode 100644 src/main/java/com/kntro/reqsai/integrations/infrastructure/crypto/IntegrationsCryptoConfiguration.java create mode 100644 src/main/java/com/kntro/reqsai/integrations/infrastructure/exception/IntegrationsInfrastructureError.java create mode 100644 src/main/java/com/kntro/reqsai/integrations/infrastructure/exception/IntegrationsInfrastructureExceptions.java create mode 100644 src/main/java/com/kntro/reqsai/integrations/infrastructure/persistence/converters/EncryptedStringConverter.java create mode 100644 src/main/java/com/kntro/reqsai/integrations/package-info.java diff --git a/src/main/java/com/kntro/reqsai/integrations/infrastructure/crypto/AesGcmCipher.java b/src/main/java/com/kntro/reqsai/integrations/infrastructure/crypto/AesGcmCipher.java new file mode 100644 index 00000000..fefcc61f --- /dev/null +++ b/src/main/java/com/kntro/reqsai/integrations/infrastructure/crypto/AesGcmCipher.java @@ -0,0 +1,78 @@ +package com.kntro.reqsai.integrations.infrastructure.crypto; + +import com.kntro.reqsai.integrations.infrastructure.exception.IntegrationsInfrastructureExceptions; + +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.nio.ByteBuffer; +import java.security.SecureRandom; +import java.util.Base64; + +/** + * AES-256-GCM symmetric encryption for integration secrets at rest (ADR-0022). + *

+ * Each value gets a fresh random 12-byte IV, prepended to the ciphertext+tag so decryption is + * self-describing: the stored bytes are {@code IV(12) || ciphertext||tag}. The key is a base64-encoded + * 32-byte value supplied at construction (from {@code INTEGRATIONS_ENCRYPTION_KEY}). Never logs + * plaintext or key material. + */ +public final class AesGcmCipher { + + private static final String TRANSFORMATION = "AES/GCM/NoPadding"; + private static final int IV_LENGTH = 12; + private static final int TAG_LENGTH_BITS = 128; + private static final int KEY_LENGTH_BYTES = 32; + + private final SecretKeySpec key; + private final SecureRandom random = new SecureRandom(); + + /** @param base64Key base64-encoded 32-byte (AES-256) key */ + public AesGcmCipher(String base64Key) { + byte[] raw; + try { + raw = Base64.getDecoder().decode(base64Key.strip()); + } catch (IllegalArgumentException e) { + throw IntegrationsInfrastructureExceptions.encryptionError( + "INTEGRATIONS_ENCRYPTION_KEY is not valid base64", e); + } + if (raw.length != KEY_LENGTH_BYTES) { + throw IntegrationsInfrastructureExceptions.encryptionError( + "INTEGRATIONS_ENCRYPTION_KEY must decode to 32 bytes (AES-256), got " + raw.length, null); + } + this.key = new SecretKeySpec(raw, "AES"); + } + + /** Encrypts {@code plaintext} → {@code IV || ciphertext+tag}. */ + public byte[] encrypt(byte[] plaintext) { + try { + byte[] iv = new byte[IV_LENGTH]; + random.nextBytes(iv); + Cipher cipher = Cipher.getInstance(TRANSFORMATION); + cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(TAG_LENGTH_BITS, iv)); + byte[] ciphertext = cipher.doFinal(plaintext); + return ByteBuffer.allocate(iv.length + ciphertext.length).put(iv).put(ciphertext).array(); + } catch (Exception e) { + throw IntegrationsInfrastructureExceptions.encryptionError("encrypt", e); + } + } + + /** Decrypts {@code IV || ciphertext+tag} produced by {@link #encrypt(byte[])}. */ + public byte[] decrypt(byte[] stored) { + try { + if (stored.length <= IV_LENGTH) { + throw new IllegalArgumentException("ciphertext too short"); + } + ByteBuffer buffer = ByteBuffer.wrap(stored); + byte[] iv = new byte[IV_LENGTH]; + buffer.get(iv); + byte[] ciphertext = new byte[buffer.remaining()]; + buffer.get(ciphertext); + Cipher cipher = Cipher.getInstance(TRANSFORMATION); + cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(TAG_LENGTH_BITS, iv)); + return cipher.doFinal(ciphertext); + } catch (Exception e) { + throw IntegrationsInfrastructureExceptions.encryptionError("decrypt", e); + } + } +} diff --git a/src/main/java/com/kntro/reqsai/integrations/infrastructure/crypto/IntegrationsCryptoConfiguration.java b/src/main/java/com/kntro/reqsai/integrations/infrastructure/crypto/IntegrationsCryptoConfiguration.java new file mode 100644 index 00000000..f4ba3319 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/integrations/infrastructure/crypto/IntegrationsCryptoConfiguration.java @@ -0,0 +1,43 @@ +package com.kntro.reqsai.integrations.infrastructure.crypto; + +import com.kntro.reqsai.integrations.infrastructure.exception.IntegrationsInfrastructureExceptions; +import com.kntro.reqsai.integrations.infrastructure.persistence.converters.EncryptedStringConverter; +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Wires the AES-256-GCM cipher used to encrypt integration secrets at rest (ADR-0022) and injects it + * into the Hibernate-instantiated {@link EncryptedStringConverter} via its static holder. + *

+ * The key comes from {@code INTEGRATIONS_ENCRYPTION_KEY} (base64, 32 bytes). It is required for the + * integrations feature; if absent the context fails fast at startup with a clear message rather than + * only when a token is first persisted. + */ +@Configuration +@Slf4j +public class IntegrationsCryptoConfiguration { + + private final AesGcmCipher cipher; + + public IntegrationsCryptoConfiguration(@Value("${reqsai.integrations.encryption-key:}") String base64Key) { + if (base64Key == null || base64Key.isBlank()) { + throw IntegrationsInfrastructureExceptions.encryptionError( + "INTEGRATIONS_ENCRYPTION_KEY is not configured", null); + } + this.cipher = new AesGcmCipher(base64Key); + } + + @Bean + AesGcmCipher integrationsCipher() { + return cipher; + } + + @PostConstruct + void wireConverter() { + EncryptedStringConverter.setCipher(cipher); + log.info("Integrations secret encryption initialized (AES-256-GCM)"); + } +} diff --git a/src/main/java/com/kntro/reqsai/integrations/infrastructure/exception/IntegrationsInfrastructureError.java b/src/main/java/com/kntro/reqsai/integrations/infrastructure/exception/IntegrationsInfrastructureError.java new file mode 100644 index 00000000..daeda82e --- /dev/null +++ b/src/main/java/com/kntro/reqsai/integrations/infrastructure/exception/IntegrationsInfrastructureError.java @@ -0,0 +1,33 @@ +package com.kntro.reqsai.integrations.infrastructure.exception; + +import com.kntro.reqsai.shared.domain.exception.ErrorCatalog; +import org.springframework.http.HttpStatus; + +/** + * Error codes for external-service and crypto failures in the Integrations bounded context (ADR-0022). + * These are infrastructure concerns (Jira reachability/auth, encryption) and must NOT live in + * {@link com.kntro.reqsai.integrations.domain.exception.IntegrationsError}. + */ +public enum IntegrationsInfrastructureError implements ErrorCatalog { + + JIRA_AUTH_FAILED(HttpStatus.UNAUTHORIZED), + JIRA_UNREACHABLE(HttpStatus.BAD_GATEWAY), + JIRA_PUSH_FAILED(HttpStatus.BAD_GATEWAY), + INTEGRATION_ENCRYPTION_ERROR(HttpStatus.INTERNAL_SERVER_ERROR); + + private final HttpStatus status; + + IntegrationsInfrastructureError(HttpStatus status) { + this.status = status; + } + + @Override + public String code() { + return name(); + } + + @Override + public HttpStatus status() { + return status; + } +} diff --git a/src/main/java/com/kntro/reqsai/integrations/infrastructure/exception/IntegrationsInfrastructureExceptions.java b/src/main/java/com/kntro/reqsai/integrations/infrastructure/exception/IntegrationsInfrastructureExceptions.java new file mode 100644 index 00000000..8526de03 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/integrations/infrastructure/exception/IntegrationsInfrastructureExceptions.java @@ -0,0 +1,36 @@ +package com.kntro.reqsai.integrations.infrastructure.exception; + +import com.kntro.reqsai.shared.domain.exception.InfrastructureException; + +/** + * Factory for Integrations infrastructure exceptions — the infrastructure counterpart of + * {@link com.kntro.reqsai.integrations.domain.exception.IntegrationsExceptions}. Adapters use this + * factory instead of constructing {@link InfrastructureException} inline. Messages never include the + * Jira token. + */ +public final class IntegrationsInfrastructureExceptions { + + private IntegrationsInfrastructureExceptions() { + throw new UnsupportedOperationException("Utility class - do not instantiate"); + } + + public static InfrastructureException jiraAuthFailed() { + return new InfrastructureException(IntegrationsInfrastructureError.JIRA_AUTH_FAILED, + "Jira rejected the credentials (401/403)", null); + } + + public static InfrastructureException jiraUnreachable(String reason, Throwable cause) { + return new InfrastructureException(IntegrationsInfrastructureError.JIRA_UNREACHABLE, + "Jira is unreachable: " + reason, cause); + } + + public static InfrastructureException jiraPushFailed(String reason) { + return new InfrastructureException(IntegrationsInfrastructureError.JIRA_PUSH_FAILED, + "Jira rejected the issue creation: " + reason, null); + } + + public static InfrastructureException encryptionError(String reason, Throwable cause) { + return new InfrastructureException(IntegrationsInfrastructureError.INTEGRATION_ENCRYPTION_ERROR, + "Integration secret encryption failed: " + reason, cause); + } +} diff --git a/src/main/java/com/kntro/reqsai/integrations/infrastructure/persistence/converters/EncryptedStringConverter.java b/src/main/java/com/kntro/reqsai/integrations/infrastructure/persistence/converters/EncryptedStringConverter.java new file mode 100644 index 00000000..ac421fb9 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/integrations/infrastructure/persistence/converters/EncryptedStringConverter.java @@ -0,0 +1,53 @@ +package com.kntro.reqsai.integrations.infrastructure.persistence.converters; + +import com.kntro.reqsai.integrations.infrastructure.crypto.AesGcmCipher; +import com.kntro.reqsai.integrations.infrastructure.exception.IntegrationsInfrastructureExceptions; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; +import org.jspecify.annotations.Nullable; + +import java.nio.charset.StandardCharsets; + +/** + * JPA converter that encrypts a {@code String} attribute (the Jira API token) to a {@code byte[]} + * ({@code secret_ciphertext} BYTEA) with AES-256-GCM and decrypts it on load (ADR-0022). + *

+ * JPA converters are instantiated by Hibernate, not Spring, so the {@link AesGcmCipher} is supplied + * through a static holder set once at startup by {@code IntegrationsCryptoConfiguration}. A missing + * cipher (no key configured) surfaces as {@code INTEGRATION_ENCRYPTION_ERROR} rather than a null token. + */ +@Converter +public class EncryptedStringConverter implements AttributeConverter { + + private static volatile @Nullable AesGcmCipher cipher; + + /** Wired once at startup by the crypto configuration. */ + public static void setCipher(AesGcmCipher aesGcmCipher) { + cipher = aesGcmCipher; + } + + @Override + public byte @Nullable [] convertToDatabaseColumn(@Nullable String attribute) { + if (attribute == null) { + return null; + } + return cipher().encrypt(attribute.getBytes(StandardCharsets.UTF_8)); + } + + @Override + public @Nullable String convertToEntityAttribute(byte @Nullable [] dbData) { + if (dbData == null) { + return null; + } + return new String(cipher().decrypt(dbData), StandardCharsets.UTF_8); + } + + private static AesGcmCipher cipher() { + AesGcmCipher c = cipher; + if (c == null) { + throw IntegrationsInfrastructureExceptions.encryptionError( + "encryption cipher is not configured (INTEGRATIONS_ENCRYPTION_KEY missing)", null); + } + return c; + } +} diff --git a/src/main/java/com/kntro/reqsai/integrations/package-info.java b/src/main/java/com/kntro/reqsai/integrations/package-info.java new file mode 100644 index 00000000..eb0bdff3 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/integrations/package-info.java @@ -0,0 +1,15 @@ +/** + * Integrations — third-party tracker connections and story push bounded context (ADR-0022). + *

+ * Extensible provider model whose first implementation is Jira Cloud. Credentials live at the + * organization level ({@code IntegrationConnection}, encrypted API token); the push + * target (Jira project key + issue type) lives at the project level + * ({@code ProjectIntegrationTarget}). Owners: Jhosepmyr. + *

+ * Layers: {@code api}, {@code domain}, {@code application}, {@code infrastructure}, {@code interfaces}. + * Depends on the OPEN {@code shared} module, the {@code workspace::api} named interface (org/project + * authorization context) and the {@code discovery::api} named interface (reading user stories to push). + */ +@org.springframework.modulith.ApplicationModule( + allowedDependencies = {"shared", "workspace::api", "discovery::api"}) +package com.kntro.reqsai.integrations; diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 66c524ee..b1874b96 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -28,6 +28,12 @@ spring: pgvector: initialize-schema: true +reqsai: + integrations: + # Local-only default AES-256 key (base64 of bytes 0..31) so the app boots without a .env in dev. + # Override with a real INTEGRATIONS_ENCRYPTION_KEY anywhere it matters. NEVER use this in prod. + encryption-key: ${INTEGRATIONS_ENCRYPTION_KEY:AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=} + logging: level: com.kntro.reqsai: DEBUG diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 7f7f2eb6..77839dbe 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -154,6 +154,10 @@ reqsai: webhook-secret: ${STRIPE_WEBHOOK_SECRET:} success-url: ${STRIPE_SUCCESS_URL:${WEB_APP_URL:http://localhost:4200}/billing/success} cancel-url: ${STRIPE_CANCEL_URL:${WEB_APP_URL:http://localhost:4200}/billing/cancel} + integrations: + # Base64-encoded 32-byte (AES-256) key used to encrypt third-party integration secrets at rest + # (ADR-0023). Required for the integrations feature; keep it out of source control (.env / secret). + encryption-key: ${INTEGRATIONS_ENCRYPTION_KEY:} jwt: private-key-path: ${JWT_PRIVATE_KEY_PATH:classpath:certs/private_key.pem} private-key-pem: ${JWT_PRIVATE_KEY_PEM:} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 4eef284a..c10b0a97 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -21,6 +21,10 @@ reqsai: jwt: public-key-path: classpath:certs/public_key.pem issuer: reqsai-test + integrations: + # Deterministic non-secret AES-256 key (base64 of bytes 0..31) so tests encrypt/decrypt integration + # secrets without a .env. NEVER use this key outside tests/local dev. + encryption-key: AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8= cors: allowed-origins: http://localhost:4200 allow-credentials: true From 1fe809650243c79d172c4fa67d60ba96539df20f Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Mon, 6 Jul 2026 19:23:49 -0500 Subject: [PATCH 19/72] feat(integrations): add domain aggregates, jira restclient adapter and jpa persistence --- .../domain/exception/IntegrationsError.java | 33 ++++ .../exception/IntegrationsExceptions.java | 50 ++++++ .../domain/model/ConnectionStatus.java | 19 +++ .../domain/model/IntegrationConnection.java | 93 +++++++++++ .../domain/model/IntegrationProviderType.java | 10 ++ .../model/ProjectIntegrationTarget.java | 63 ++++++++ .../infrastructure/jira/JiraAdfBuilder.java | 71 ++++++++ .../infrastructure/jira/JiraClient.java | 151 ++++++++++++++++++ .../infrastructure/jira/JiraProvider.java | 53 ++++++ ...ntegrationConnectionRepositoryAdapter.java | 52 ++++++ ...ectIntegrationTargetRepositoryAdapter.java | 33 ++++ .../IntegrationConnectionJpaRepository.java | 20 +++ ...ProjectIntegrationTargetJpaRepository.java | 12 ++ 13 files changed, 660 insertions(+) create mode 100644 src/main/java/com/kntro/reqsai/integrations/domain/exception/IntegrationsError.java create mode 100644 src/main/java/com/kntro/reqsai/integrations/domain/exception/IntegrationsExceptions.java create mode 100644 src/main/java/com/kntro/reqsai/integrations/domain/model/ConnectionStatus.java create mode 100644 src/main/java/com/kntro/reqsai/integrations/domain/model/IntegrationConnection.java create mode 100644 src/main/java/com/kntro/reqsai/integrations/domain/model/IntegrationProviderType.java create mode 100644 src/main/java/com/kntro/reqsai/integrations/domain/model/ProjectIntegrationTarget.java create mode 100644 src/main/java/com/kntro/reqsai/integrations/infrastructure/jira/JiraAdfBuilder.java create mode 100644 src/main/java/com/kntro/reqsai/integrations/infrastructure/jira/JiraClient.java create mode 100644 src/main/java/com/kntro/reqsai/integrations/infrastructure/jira/JiraProvider.java create mode 100644 src/main/java/com/kntro/reqsai/integrations/infrastructure/persistence/adapters/IntegrationConnectionRepositoryAdapter.java create mode 100644 src/main/java/com/kntro/reqsai/integrations/infrastructure/persistence/adapters/ProjectIntegrationTargetRepositoryAdapter.java create mode 100644 src/main/java/com/kntro/reqsai/integrations/infrastructure/persistence/repositories/IntegrationConnectionJpaRepository.java create mode 100644 src/main/java/com/kntro/reqsai/integrations/infrastructure/persistence/repositories/ProjectIntegrationTargetJpaRepository.java diff --git a/src/main/java/com/kntro/reqsai/integrations/domain/exception/IntegrationsError.java b/src/main/java/com/kntro/reqsai/integrations/domain/exception/IntegrationsError.java new file mode 100644 index 00000000..e790d91c --- /dev/null +++ b/src/main/java/com/kntro/reqsai/integrations/domain/exception/IntegrationsError.java @@ -0,0 +1,33 @@ +package com.kntro.reqsai.integrations.domain.exception; + +import com.kntro.reqsai.shared.domain.exception.ErrorCatalog; +import org.springframework.http.HttpStatus; + +/** + * Domain (business-rule) error codes owned by the Integrations bounded context (ADR-0022). Mapped to + * RFC 9457 {@code ProblemDetail} by the shared {@code GlobalExceptionHandler}. External-service + * failures live in {@link com.kntro.reqsai.integrations.infrastructure.exception.IntegrationsInfrastructureError}. + */ +public enum IntegrationsError implements ErrorCatalog { + + INTEGRATION_CONNECTION_NOT_FOUND(HttpStatus.NOT_FOUND), + INTEGRATION_ALREADY_CONNECTED(HttpStatus.CONFLICT), + INTEGRATION_TARGET_NOT_CONFIGURED(HttpStatus.CONFLICT), + JIRA_PROJECT_NOT_FOUND(HttpStatus.NOT_FOUND); + + private final HttpStatus status; + + IntegrationsError(HttpStatus status) { + this.status = status; + } + + @Override + public String code() { + return name(); + } + + @Override + public HttpStatus status() { + return status; + } +} diff --git a/src/main/java/com/kntro/reqsai/integrations/domain/exception/IntegrationsExceptions.java b/src/main/java/com/kntro/reqsai/integrations/domain/exception/IntegrationsExceptions.java new file mode 100644 index 00000000..7344f1d7 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/integrations/domain/exception/IntegrationsExceptions.java @@ -0,0 +1,50 @@ +package com.kntro.reqsai.integrations.domain.exception; + +import com.kntro.reqsai.shared.domain.exception.DomainException; +import com.kntro.reqsai.shared.domain.exception.EntityNotFoundException; + +import java.util.UUID; + +/** + * Factory for Integrations domain exceptions — the context-specific counterpart of the shared + * {@code Exceptions}. Not-found cases return {@link EntityNotFoundException}; the rest a + * {@link DomainException} carrying an {@link IntegrationsError}. + */ +public final class IntegrationsExceptions { + + private IntegrationsExceptions() { + throw new UnsupportedOperationException("Utility class - do not instantiate"); + } + + public static EntityNotFoundException connectionNotFound(UUID connectionId) { + return new EntityNotFoundException(IntegrationsError.INTEGRATION_CONNECTION_NOT_FOUND, + "Integration connection not found: " + connectionId); + } + + /** A project has no Jira target configured — 404 for the GET target endpoint. */ + public static EntityNotFoundException targetNotFound(UUID projectId) { + return new EntityNotFoundException(IntegrationsError.INTEGRATION_CONNECTION_NOT_FOUND, + "No integration target configured for project " + projectId); + } + + public static DomainException alreadyConnected(UUID organizationId, String provider) { + return new DomainException(IntegrationsError.INTEGRATION_ALREADY_CONNECTED, + "An active %s integration already exists for organization %s".formatted(provider, organizationId)); + } + + public static DomainException targetNotConfigured(UUID projectId) { + return new DomainException(IntegrationsError.INTEGRATION_TARGET_NOT_CONFIGURED, + "No integration target configured for project " + projectId); + } + + public static EntityNotFoundException jiraProjectNotFound(String jiraProjectKey) { + return new EntityNotFoundException(IntegrationsError.JIRA_PROJECT_NOT_FOUND, + "Jira project not found: " + jiraProjectKey); + } + + /** A story to push was not found in the project — 404 for the push endpoints. */ + public static EntityNotFoundException storyNotFound(UUID storyId) { + return new EntityNotFoundException(IntegrationsError.INTEGRATION_CONNECTION_NOT_FOUND, + "Story not found in project: " + storyId); + } +} diff --git a/src/main/java/com/kntro/reqsai/integrations/domain/model/ConnectionStatus.java b/src/main/java/com/kntro/reqsai/integrations/domain/model/ConnectionStatus.java new file mode 100644 index 00000000..7aa928ed --- /dev/null +++ b/src/main/java/com/kntro/reqsai/integrations/domain/model/ConnectionStatus.java @@ -0,0 +1,19 @@ +package com.kntro.reqsai.integrations.domain.model; + +/** + * Lifecycle of an {@link IntegrationConnection}. A connection is {@code CONNECTED} while its stored + * credential last verified successfully; a failed verification flips it to {@code DEGRADED} without + * losing the credential; deleting it removes the row. The partial unique index treats anything other + * than {@code DISCONNECTED} as "active", so at most one active connection exists per org per provider. + */ +public enum ConnectionStatus { + + /** Credential present and last verification succeeded. */ + CONNECTED, + + /** Credential present but the last verification failed (auth or reachability). */ + DEGRADED, + + /** Retired connection (not counted by the single-active-connection unique index). */ + DISCONNECTED +} diff --git a/src/main/java/com/kntro/reqsai/integrations/domain/model/IntegrationConnection.java b/src/main/java/com/kntro/reqsai/integrations/domain/model/IntegrationConnection.java new file mode 100644 index 00000000..db0754b6 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/integrations/domain/model/IntegrationConnection.java @@ -0,0 +1,93 @@ +package com.kntro.reqsai.integrations.domain.model; + +import com.kntro.reqsai.integrations.infrastructure.persistence.converters.EncryptedStringConverter; +import com.kntro.reqsai.shared.domain.model.AggregateRoot; +import com.kntro.reqsai.shared.domain.support.Assert; +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Table; +import lombok.Getter; +import org.jspecify.annotations.Nullable; + +import java.time.Instant; +import java.util.UUID; + +/** + * Organization-scoped third-party integration connection (ADR-0022). Holds the provider, the Jira site + * URL + account email, and the API token encrypted at rest (the {@code apiToken} field + * is transparently encrypted/decrypted by {@link EncryptedStringConverter} into the + * {@code secret_ciphertext} BYTEA column). The token is never exposed by any response mapper. + */ +@Entity +@Table(name = "integration_connections") +@Getter +public class IntegrationConnection extends AggregateRoot { + + private static final int SITE_URL_MAX = 500; + private static final int EMAIL_MAX = 320; + + @Column(name = "organization_id", columnDefinition = "uuid", nullable = false, updatable = false) + private UUID organizationId; + + @Enumerated(EnumType.STRING) + @Column(name = "provider", nullable = false, length = 32, updatable = false) + private IntegrationProviderType provider; + + @Column(name = "site_url", nullable = false, length = SITE_URL_MAX) + private String siteUrl; + + @Column(name = "email", nullable = false, length = EMAIL_MAX) + private String email; + + /** Plaintext in memory only; persisted encrypted via {@link EncryptedStringConverter}. */ + @Convert(converter = EncryptedStringConverter.class) + @Column(name = "secret_ciphertext", nullable = false) + private String apiToken; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 32) + private ConnectionStatus status; + + @Column(name = "last_verified_at") + private @Nullable Instant lastVerifiedAt; + + protected IntegrationConnection() { + super(); + } + + /** + * Creates a Jira connection. The caller is expected to have verified the credential (via the + * provider) before persisting; {@code verifiedAt} records that success. + */ + public IntegrationConnection(UUID organizationId, IntegrationProviderType provider, + String siteUrl, String email, String apiToken, Instant verifiedAt) { + super(); + this.organizationId = Assert.notNull(organizationId, "organizationId"); + this.provider = Assert.notNull(provider, "provider"); + this.siteUrl = normalizeSiteUrl(siteUrl); + this.email = Assert.maxLength(Assert.notBlank(email, "email"), "email", EMAIL_MAX); + this.apiToken = Assert.notBlank(apiToken, "apiToken"); + this.status = ConnectionStatus.CONNECTED; + this.lastVerifiedAt = Assert.notNull(verifiedAt, "verifiedAt"); + } + + /** Normalizes the Jira base site URL, trimming a trailing slash so path concatenation is clean. */ + public static String normalizeSiteUrl(String siteUrl) { + String trimmed = Assert.maxLength(Assert.notBlank(siteUrl, "siteUrl"), "siteUrl", SITE_URL_MAX); + return trimmed.endsWith("/") ? trimmed.substring(0, trimmed.length() - 1) : trimmed; + } + + /** Marks a successful credential verification. */ + public void markVerified(Instant when) { + this.status = ConnectionStatus.CONNECTED; + this.lastVerifiedAt = Assert.notNull(when, "when"); + } + + /** Marks a failed credential verification without discarding the stored credential. */ + public void markDegraded() { + this.status = ConnectionStatus.DEGRADED; + } +} diff --git a/src/main/java/com/kntro/reqsai/integrations/domain/model/IntegrationProviderType.java b/src/main/java/com/kntro/reqsai/integrations/domain/model/IntegrationProviderType.java new file mode 100644 index 00000000..bad409c7 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/integrations/domain/model/IntegrationProviderType.java @@ -0,0 +1,10 @@ +package com.kntro.reqsai.integrations.domain.model; + +/** + * Supported third-party integration providers. Only {@code JIRA} exists today; the value is stored on + * {@code IntegrationConnection} and drives provider-adapter selection (ADR-0022), so adding a provider + * (e.g. Azure DevOps) is additive. + */ +public enum IntegrationProviderType { + JIRA +} diff --git a/src/main/java/com/kntro/reqsai/integrations/domain/model/ProjectIntegrationTarget.java b/src/main/java/com/kntro/reqsai/integrations/domain/model/ProjectIntegrationTarget.java new file mode 100644 index 00000000..5465b05d --- /dev/null +++ b/src/main/java/com/kntro/reqsai/integrations/domain/model/ProjectIntegrationTarget.java @@ -0,0 +1,63 @@ +package com.kntro.reqsai.integrations.domain.model; + +import com.kntro.reqsai.shared.domain.model.AggregateRoot; +import com.kntro.reqsai.shared.domain.support.Assert; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; + +import java.util.UUID; + +/** + * Project-scoped push target (ADR-0022): the Jira project key + issue type a Reqs-AI project's stories + * are pushed to, referencing the org-level {@link IntegrationConnection}. Exactly one per project (the + * {@code PUT .../target} endpoint upserts this single row). + */ +@Entity +@Table(name = "project_integration_targets") +@Getter +public class ProjectIntegrationTarget extends AggregateRoot { + + private static final int KEY_MAX = 100; + private static final int TYPE_MAX = 100; + + @Column(name = "project_id", columnDefinition = "uuid", nullable = false, updatable = false) + private UUID projectId; + + @Column(name = "connection_id", columnDefinition = "uuid", nullable = false) + private UUID connectionId; + + @Column(name = "jira_project_key", nullable = false, length = KEY_MAX) + private String jiraProjectKey; + + @Column(name = "issue_type_name", nullable = false, length = TYPE_MAX) + private String issueTypeName; + + protected ProjectIntegrationTarget() { + super(); + } + + public ProjectIntegrationTarget(UUID projectId, UUID connectionId, String jiraProjectKey, String issueTypeName) { + super(); + this.projectId = Assert.notNull(projectId, "projectId"); + this.connectionId = Assert.notNull(connectionId, "connectionId"); + this.jiraProjectKey = normalizeKey(jiraProjectKey); + this.issueTypeName = normalizeType(issueTypeName); + } + + public static String normalizeKey(String key) { + return Assert.maxLength(Assert.notBlank(key, "jiraProjectKey"), "jiraProjectKey", KEY_MAX); + } + + public static String normalizeType(String type) { + return Assert.maxLength(Assert.notBlank(type, "issueTypeName"), "issueTypeName", TYPE_MAX); + } + + /** Re-points this target at a (possibly different) connection, Jira project and issue type. */ + public void update(UUID connectionId, String jiraProjectKey, String issueTypeName) { + this.connectionId = Assert.notNull(connectionId, "connectionId"); + this.jiraProjectKey = normalizeKey(jiraProjectKey); + this.issueTypeName = normalizeType(issueTypeName); + } +} diff --git a/src/main/java/com/kntro/reqsai/integrations/infrastructure/jira/JiraAdfBuilder.java b/src/main/java/com/kntro/reqsai/integrations/infrastructure/jira/JiraAdfBuilder.java new file mode 100644 index 00000000..240e04f0 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/integrations/infrastructure/jira/JiraAdfBuilder.java @@ -0,0 +1,71 @@ +package com.kntro.reqsai.integrations.infrastructure.jira; + +import com.kntro.reqsai.discovery.api.AcceptanceCriterionView; +import com.kntro.reqsai.discovery.api.StoryView; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * Builds a Jira issue description as an Atlassian Document Format (ADF) document + * (Jira Cloud REST v3 requires ADF, not wiki markup) from a Reqs-AI {@link StoryView}. + *

+ * Layout: a "As {role}, I want to {action}, so that {benefit}." paragraph, the priority/story-point + * metadata, then an "Acceptance Criteria" heading with one bullet per criterion rendered as + * {@code Given … When … Then …}. The returned map is the {@code description} field value. + */ +public final class JiraAdfBuilder { + + private JiraAdfBuilder() { + throw new UnsupportedOperationException("Utility class"); + } + + /** Builds the ADF {@code doc} node for the given story. */ + public static Map buildDescription(StoryView story) { + List content = new ArrayList<>(); + + content.add(paragraph("As %s, I want to %s, so that %s.".formatted( + story.role(), story.action(), story.benefit()))); + + String meta = "Priority: " + story.priority() + + (story.storyPoints() != null ? " • Story points: " + story.storyPoints() : ""); + content.add(paragraph(meta)); + + List criteria = story.acceptanceCriteria(); + if (criteria != null && !criteria.isEmpty()) { + content.add(heading("Acceptance Criteria")); + content.add(bulletList(criteria)); + } + + return Map.of("type", "doc", "version", 1, "content", content); + } + + private static Map paragraph(String text) { + return Map.of("type", "paragraph", "content", List.of(textNode(text))); + } + + private static Map heading(String text) { + return Map.of("type", "heading", "attrs", Map.of("level", 3), + "content", List.of(textNode(text))); + } + + private static Map bulletList(List criteria) { + List items = new ArrayList<>(); + for (AcceptanceCriterionView c : criteria) { + StringBuilder line = new StringBuilder(); + if (c.scenario() != null && !c.scenario().isBlank()) { + line.append(c.scenario()).append(": "); + } + line.append("Given ").append(c.given()) + .append(", When ").append(c.when()) + .append(", Then ").append(c.then()).append('.'); + items.add(Map.of("type", "listItem", "content", List.of(paragraph(line.toString())))); + } + return Map.of("type", "bulletList", "content", items); + } + + private static Map textNode(String text) { + return Map.of("type", "text", "text", text); + } +} diff --git a/src/main/java/com/kntro/reqsai/integrations/infrastructure/jira/JiraClient.java b/src/main/java/com/kntro/reqsai/integrations/infrastructure/jira/JiraClient.java new file mode 100644 index 00000000..25f31caf --- /dev/null +++ b/src/main/java/com/kntro/reqsai/integrations/infrastructure/jira/JiraClient.java @@ -0,0 +1,151 @@ +package com.kntro.reqsai.integrations.infrastructure.jira; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.kntro.reqsai.integrations.infrastructure.exception.IntegrationsInfrastructureExceptions; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.List; +import java.util.Map; + +/** + * Outbound Jira Cloud REST v3 client (ADR-0022). Mirrors the {@code AssemblyAiAdapter} RestClient style: + * a per-call {@link RestClient}, typed Jackson response records, and HTTP-status → infrastructure + * exception mapping. Authentication is basic auth with the API token + * ({@code Authorization: Basic base64(email:token)}); the token is never logged nor placed in exceptions. + *
    + *
  • 401/403 → {@code JIRA_AUTH_FAILED}
  • + *
  • connect/timeout/5xx → {@code JIRA_UNREACHABLE}
  • + *
  • 400 on create → {@code JIRA_PUSH_FAILED}
  • + *
+ */ +@Component +@Slf4j +public class JiraClient { + + private final RestClient restClient = RestClient.create(); + + /** GET /rest/api/3/myself → the authenticated account's display name. */ + public String verify(String siteUrl, String email, String token) { + Myself me = exchange(() -> restClient.get() + .uri(siteUrl + "/rest/api/3/myself") + .header("Authorization", basic(email, token)) + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .onStatus(HttpStatusCode::isError, (req, res) -> mapError(res.getStatusCode(), false)) + .body(Myself.class), "verify"); + return me != null ? me.displayName() : ""; + } + + /** GET /rest/api/3/project/search → visible projects. */ + public List listProjects(String siteUrl, String email, String token) { + ProjectSearch search = exchange(() -> restClient.get() + .uri(siteUrl + "/rest/api/3/project/search?maxResults=100") + .header("Authorization", basic(email, token)) + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .onStatus(HttpStatusCode::isError, (req, res) -> mapError(res.getStatusCode(), false)) + .body(ProjectSearch.class), "listProjects"); + return search == null || search.values() == null ? List.of() : search.values(); + } + + /** GET /rest/api/3/issuetype/project?projectId= is key-based; we use the simpler global list. */ + public List listIssueTypes(String siteUrl, String email, String token, String projectKey) { + List types = exchange(() -> restClient.get() + .uri(siteUrl + "/rest/api/3/issuetype") + .header("Authorization", basic(email, token)) + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .onStatus(HttpStatusCode::isError, (req, res) -> mapError(res.getStatusCode(), false)) + .body(ISSUE_TYPE_LIST), "listIssueTypes"); + return types == null ? List.of() : types; + } + + /** POST /rest/api/3/issue → the created issue's key + self URL. */ + public CreatedIssue createIssue(String siteUrl, String email, String token, + String projectKey, String issueTypeName, String summary, + Map descriptionAdf) { + Map fields = Map.of( + "project", Map.of("key", projectKey), + "issuetype", Map.of("name", issueTypeName), + "summary", summary, + "description", descriptionAdf); + CreatedIssue created = exchange(() -> restClient.post() + .uri(siteUrl + "/rest/api/3/issue") + .header("Authorization", basic(email, token)) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .body(Map.of("fields", fields)) + .retrieve() + .onStatus(HttpStatusCode::isError, (req, res) -> mapError(res.getStatusCode(), true)) + .body(CreatedIssue.class), "createIssue"); + if (created == null || created.key() == null) { + throw IntegrationsInfrastructureExceptions.jiraPushFailed("Jira returned no issue key"); + } + return created; + } + + /** Browse URL for a created issue. */ + public String browseUrl(String siteUrl, String issueKey) { + return siteUrl + "/browse/" + issueKey; + } + + // Helpers + + private static String basic(String email, String token) { + String raw = email + ":" + token; + return "Basic " + Base64.getEncoder().encodeToString(raw.getBytes(StandardCharsets.UTF_8)); + } + + /** + * Maps an error status inside the RestClient exchange. {@code onCreate} selects the 400 → push-failed + * mapping; otherwise 400 falls through to unreachable. Throwing here aborts the call with a mapped, + * token-free exception. + */ + private static RuntimeException mapError(HttpStatusCode status, boolean onCreate) { + if (status.value() == 401 || status.value() == 403) { + return IntegrationsInfrastructureExceptions.jiraAuthFailed(); + } + if (onCreate && status.value() == 400) { + return IntegrationsInfrastructureExceptions.jiraPushFailed("Jira rejected the request (400)"); + } + return IntegrationsInfrastructureExceptions.jiraUnreachable("HTTP " + status.value(), null); + } + + /** Runs a RestClient call, translating transport-level failures (connect/timeout) to JIRA_UNREACHABLE. */ + private T exchange(java.util.function.Supplier call, String op) { + try { + return call.get(); + } catch (com.kntro.reqsai.shared.domain.exception.InfrastructureException mapped) { + throw mapped; // already mapped by onStatus + } catch (Exception e) { + log.warn("Jira {} failed: {}", op, e.getMessage()); + throw IntegrationsInfrastructureExceptions.jiraUnreachable(op, e); + } + } + + private static final org.springframework.core.ParameterizedTypeReference> ISSUE_TYPE_LIST = + new org.springframework.core.ParameterizedTypeReference<>() {}; + + // Jackson-bound response records + + @JsonIgnoreProperties(ignoreUnknown = true) + private record Myself(String displayName) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + public record JiraProject(String key, String name) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + private record ProjectSearch(List values) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + public record JiraIssueType(String id, String name) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + public record CreatedIssue(String key, String self) {} +} diff --git a/src/main/java/com/kntro/reqsai/integrations/infrastructure/jira/JiraProvider.java b/src/main/java/com/kntro/reqsai/integrations/infrastructure/jira/JiraProvider.java new file mode 100644 index 00000000..7516396e --- /dev/null +++ b/src/main/java/com/kntro/reqsai/integrations/infrastructure/jira/JiraProvider.java @@ -0,0 +1,53 @@ +package com.kntro.reqsai.integrations.infrastructure.jira; + +import com.kntro.reqsai.discovery.api.StoryView; +import com.kntro.reqsai.integrations.application.port.IntegrationProvider; +import com.kntro.reqsai.integrations.domain.model.IntegrationProviderType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; + +/** + * Jira Cloud implementation of {@link IntegrationProvider} (ADR-0022). Translates provider-neutral calls + * into {@link JiraClient} REST calls and renders the story description as ADF via {@link JiraAdfBuilder}. + */ +@Component +@RequiredArgsConstructor +public class JiraProvider implements IntegrationProvider { + + private final JiraClient jira; + + @Override + public IntegrationProviderType type() { + return IntegrationProviderType.JIRA; + } + + @Override + public String verify(ProviderCredentials c) { + return jira.verify(c.siteUrl(), c.email(), c.apiToken()); + } + + @Override + public List listProjects(ProviderCredentials c) { + return jira.listProjects(c.siteUrl(), c.email(), c.apiToken()).stream() + .map(p -> new RemoteProject(p.key(), p.name())) + .toList(); + } + + @Override + public List listIssueTypes(ProviderCredentials c, String projectKey) { + return jira.listIssueTypes(c.siteUrl(), c.email(), c.apiToken(), projectKey).stream() + .map(t -> new RemoteIssueType(t.id(), t.name())) + .toList(); + } + + @Override + public PushedIssue pushStory(ProviderCredentials c, String projectKey, String issueTypeName, StoryView story) { + Map description = JiraAdfBuilder.buildDescription(story); + JiraClient.CreatedIssue created = jira.createIssue( + c.siteUrl(), c.email(), c.apiToken(), projectKey, issueTypeName, story.title(), description); + return new PushedIssue(created.key(), jira.browseUrl(c.siteUrl(), created.key())); + } +} diff --git a/src/main/java/com/kntro/reqsai/integrations/infrastructure/persistence/adapters/IntegrationConnectionRepositoryAdapter.java b/src/main/java/com/kntro/reqsai/integrations/infrastructure/persistence/adapters/IntegrationConnectionRepositoryAdapter.java new file mode 100644 index 00000000..efb25506 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/integrations/infrastructure/persistence/adapters/IntegrationConnectionRepositoryAdapter.java @@ -0,0 +1,52 @@ +package com.kntro.reqsai.integrations.infrastructure.persistence.adapters; + +import com.kntro.reqsai.integrations.application.port.IntegrationConnectionRepository; +import com.kntro.reqsai.integrations.domain.model.ConnectionStatus; +import com.kntro.reqsai.integrations.domain.model.IntegrationConnection; +import com.kntro.reqsai.integrations.domain.model.IntegrationProviderType; +import com.kntro.reqsai.integrations.infrastructure.persistence.repositories.IntegrationConnectionJpaRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +/** Adapts the {@link IntegrationConnectionRepository} port to Spring Data JPA. */ +@Component +@RequiredArgsConstructor +public class IntegrationConnectionRepositoryAdapter implements IntegrationConnectionRepository { + + private final IntegrationConnectionJpaRepository jpa; + + @Override + public IntegrationConnection save(IntegrationConnection connection) { + return jpa.save(connection); + } + + @Override + public Optional findById(UUID id) { + return jpa.findById(id); + } + + @Override + public Optional findByIdAndOrganizationId(UUID id, UUID organizationId) { + return jpa.findByIdAndOrganizationId(id, organizationId); + } + + @Override + public List findAllByOrganizationId(UUID organizationId) { + return jpa.findAllByOrganizationIdOrderByCreatedAtDesc(organizationId); + } + + @Override + public boolean existsByOrganizationIdAndProviderAndStatusNot( + UUID organizationId, IntegrationProviderType provider, ConnectionStatus status) { + return jpa.existsByOrganizationIdAndProviderAndStatusNot(organizationId, provider, status); + } + + @Override + public void delete(IntegrationConnection connection) { + jpa.delete(connection); + } +} diff --git a/src/main/java/com/kntro/reqsai/integrations/infrastructure/persistence/adapters/ProjectIntegrationTargetRepositoryAdapter.java b/src/main/java/com/kntro/reqsai/integrations/infrastructure/persistence/adapters/ProjectIntegrationTargetRepositoryAdapter.java new file mode 100644 index 00000000..ecf13607 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/integrations/infrastructure/persistence/adapters/ProjectIntegrationTargetRepositoryAdapter.java @@ -0,0 +1,33 @@ +package com.kntro.reqsai.integrations.infrastructure.persistence.adapters; + +import com.kntro.reqsai.integrations.application.port.ProjectIntegrationTargetRepository; +import com.kntro.reqsai.integrations.domain.model.ProjectIntegrationTarget; +import com.kntro.reqsai.integrations.infrastructure.persistence.repositories.ProjectIntegrationTargetJpaRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; +import java.util.UUID; + +/** Adapts the {@link ProjectIntegrationTargetRepository} port to Spring Data JPA. */ +@Component +@RequiredArgsConstructor +public class ProjectIntegrationTargetRepositoryAdapter implements ProjectIntegrationTargetRepository { + + private final ProjectIntegrationTargetJpaRepository jpa; + + @Override + public ProjectIntegrationTarget save(ProjectIntegrationTarget target) { + return jpa.save(target); + } + + @Override + public Optional findByProjectId(UUID projectId) { + return jpa.findByProjectId(projectId); + } + + @Override + public void delete(ProjectIntegrationTarget target) { + jpa.delete(target); + } +} diff --git a/src/main/java/com/kntro/reqsai/integrations/infrastructure/persistence/repositories/IntegrationConnectionJpaRepository.java b/src/main/java/com/kntro/reqsai/integrations/infrastructure/persistence/repositories/IntegrationConnectionJpaRepository.java new file mode 100644 index 00000000..6b6dcb9c --- /dev/null +++ b/src/main/java/com/kntro/reqsai/integrations/infrastructure/persistence/repositories/IntegrationConnectionJpaRepository.java @@ -0,0 +1,20 @@ +package com.kntro.reqsai.integrations.infrastructure.persistence.repositories; + +import com.kntro.reqsai.integrations.domain.model.ConnectionStatus; +import com.kntro.reqsai.integrations.domain.model.IntegrationConnection; +import com.kntro.reqsai.integrations.domain.model.IntegrationProviderType; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface IntegrationConnectionJpaRepository extends JpaRepository { + + Optional findByIdAndOrganizationId(UUID id, UUID organizationId); + + List findAllByOrganizationIdOrderByCreatedAtDesc(UUID organizationId); + + boolean existsByOrganizationIdAndProviderAndStatusNot( + UUID organizationId, IntegrationProviderType provider, ConnectionStatus status); +} diff --git a/src/main/java/com/kntro/reqsai/integrations/infrastructure/persistence/repositories/ProjectIntegrationTargetJpaRepository.java b/src/main/java/com/kntro/reqsai/integrations/infrastructure/persistence/repositories/ProjectIntegrationTargetJpaRepository.java new file mode 100644 index 00000000..aa176d31 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/integrations/infrastructure/persistence/repositories/ProjectIntegrationTargetJpaRepository.java @@ -0,0 +1,12 @@ +package com.kntro.reqsai.integrations.infrastructure.persistence.repositories; + +import com.kntro.reqsai.integrations.domain.model.ProjectIntegrationTarget; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; +import java.util.UUID; + +public interface ProjectIntegrationTargetJpaRepository extends JpaRepository { + + Optional findByProjectId(UUID projectId); +} From 3af9ac37ac9c26d39a1ce8214a5b8f8df1264ce7 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Mon, 6 Jul 2026 19:23:56 -0500 Subject: [PATCH 20/72] feat(integrations): add cqrs commands, queries and handlers for connect, target and push --- .../command/ConnectJiraCommand.java | 12 ++++ .../command/DeleteConnectionCommand.java | 6 ++ .../command/DeleteProjectTargetCommand.java | 6 ++ .../command/PushAllStoriesCommand.java | 6 ++ .../application/command/PushStoryCommand.java | 6 ++ .../command/SaveProjectTargetCommand.java | 12 ++++ .../handler/ConnectJiraCommandHandler.java | 46 +++++++++++++++ .../DeleteConnectionCommandHandler.java | 28 +++++++++ .../DeleteProjectTargetCommandHandler.java | 24 ++++++++ .../handler/GetProjectTargetQueryHandler.java | 23 ++++++++ .../handler/ListConnectionsQueryHandler.java | 23 ++++++++ .../ListJiraIssueTypesQueryHandler.java | 34 +++++++++++ .../handler/ListJiraProjectsQueryHandler.java | 34 +++++++++++ .../handler/PushAllStoriesCommandHandler.java | 58 +++++++++++++++++++ .../handler/PushStoryCommandHandler.java | 42 ++++++++++++++ .../SaveProjectTargetCommandHandler.java | 38 ++++++++++++ .../handler/TestConnectionQueryHandler.java | 52 +++++++++++++++++ .../port/IntegrationConnectionRepository.java | 26 +++++++++ .../application/port/IntegrationProvider.java | 45 ++++++++++++++ .../ProjectIntegrationTargetRepository.java | 16 +++++ .../query/GetProjectTargetQuery.java | 6 ++ .../query/ListConnectionsQuery.java | 6 ++ .../query/ListJiraIssueTypesQuery.java | 6 ++ .../query/ListJiraProjectsQuery.java | 6 ++ .../query/TestConnectionQuery.java | 6 ++ .../application/result/BatchPushResult.java | 12 ++++ .../result/ConnectionTestResult.java | 6 ++ .../application/result/StoryPushResult.java | 29 ++++++++++ .../service/ProviderCredentialsFactory.java | 18 ++++++ .../application/service/ProviderRegistry.java | 33 +++++++++++ .../application/service/StoryPushService.java | 44 ++++++++++++++ 31 files changed, 709 insertions(+) create mode 100644 src/main/java/com/kntro/reqsai/integrations/application/command/ConnectJiraCommand.java create mode 100644 src/main/java/com/kntro/reqsai/integrations/application/command/DeleteConnectionCommand.java create mode 100644 src/main/java/com/kntro/reqsai/integrations/application/command/DeleteProjectTargetCommand.java create mode 100644 src/main/java/com/kntro/reqsai/integrations/application/command/PushAllStoriesCommand.java create mode 100644 src/main/java/com/kntro/reqsai/integrations/application/command/PushStoryCommand.java create mode 100644 src/main/java/com/kntro/reqsai/integrations/application/command/SaveProjectTargetCommand.java create mode 100644 src/main/java/com/kntro/reqsai/integrations/application/handler/ConnectJiraCommandHandler.java create mode 100644 src/main/java/com/kntro/reqsai/integrations/application/handler/DeleteConnectionCommandHandler.java create mode 100644 src/main/java/com/kntro/reqsai/integrations/application/handler/DeleteProjectTargetCommandHandler.java create mode 100644 src/main/java/com/kntro/reqsai/integrations/application/handler/GetProjectTargetQueryHandler.java create mode 100644 src/main/java/com/kntro/reqsai/integrations/application/handler/ListConnectionsQueryHandler.java create mode 100644 src/main/java/com/kntro/reqsai/integrations/application/handler/ListJiraIssueTypesQueryHandler.java create mode 100644 src/main/java/com/kntro/reqsai/integrations/application/handler/ListJiraProjectsQueryHandler.java create mode 100644 src/main/java/com/kntro/reqsai/integrations/application/handler/PushAllStoriesCommandHandler.java create mode 100644 src/main/java/com/kntro/reqsai/integrations/application/handler/PushStoryCommandHandler.java create mode 100644 src/main/java/com/kntro/reqsai/integrations/application/handler/SaveProjectTargetCommandHandler.java create mode 100644 src/main/java/com/kntro/reqsai/integrations/application/handler/TestConnectionQueryHandler.java create mode 100644 src/main/java/com/kntro/reqsai/integrations/application/port/IntegrationConnectionRepository.java create mode 100644 src/main/java/com/kntro/reqsai/integrations/application/port/IntegrationProvider.java create mode 100644 src/main/java/com/kntro/reqsai/integrations/application/port/ProjectIntegrationTargetRepository.java create mode 100644 src/main/java/com/kntro/reqsai/integrations/application/query/GetProjectTargetQuery.java create mode 100644 src/main/java/com/kntro/reqsai/integrations/application/query/ListConnectionsQuery.java create mode 100644 src/main/java/com/kntro/reqsai/integrations/application/query/ListJiraIssueTypesQuery.java create mode 100644 src/main/java/com/kntro/reqsai/integrations/application/query/ListJiraProjectsQuery.java create mode 100644 src/main/java/com/kntro/reqsai/integrations/application/query/TestConnectionQuery.java create mode 100644 src/main/java/com/kntro/reqsai/integrations/application/result/BatchPushResult.java create mode 100644 src/main/java/com/kntro/reqsai/integrations/application/result/ConnectionTestResult.java create mode 100644 src/main/java/com/kntro/reqsai/integrations/application/result/StoryPushResult.java create mode 100644 src/main/java/com/kntro/reqsai/integrations/application/service/ProviderCredentialsFactory.java create mode 100644 src/main/java/com/kntro/reqsai/integrations/application/service/ProviderRegistry.java create mode 100644 src/main/java/com/kntro/reqsai/integrations/application/service/StoryPushService.java diff --git a/src/main/java/com/kntro/reqsai/integrations/application/command/ConnectJiraCommand.java b/src/main/java/com/kntro/reqsai/integrations/application/command/ConnectJiraCommand.java new file mode 100644 index 00000000..209f199e --- /dev/null +++ b/src/main/java/com/kntro/reqsai/integrations/application/command/ConnectJiraCommand.java @@ -0,0 +1,12 @@ +package com.kntro.reqsai.integrations.application.command; + +import java.util.UUID; + +/** Connect a Jira integration at the organization level (verifies the credential, then persists it). */ +public record ConnectJiraCommand( + UUID organizationId, + String siteUrl, + String email, + String apiToken, + UUID requestedBy +) {} diff --git a/src/main/java/com/kntro/reqsai/integrations/application/command/DeleteConnectionCommand.java b/src/main/java/com/kntro/reqsai/integrations/application/command/DeleteConnectionCommand.java new file mode 100644 index 00000000..0388d349 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/integrations/application/command/DeleteConnectionCommand.java @@ -0,0 +1,6 @@ +package com.kntro.reqsai.integrations.application.command; + +import java.util.UUID; + +/** Delete an organization integration connection. */ +public record DeleteConnectionCommand(UUID organizationId, UUID connectionId, UUID requestedBy) {} diff --git a/src/main/java/com/kntro/reqsai/integrations/application/command/DeleteProjectTargetCommand.java b/src/main/java/com/kntro/reqsai/integrations/application/command/DeleteProjectTargetCommand.java new file mode 100644 index 00000000..425830ac --- /dev/null +++ b/src/main/java/com/kntro/reqsai/integrations/application/command/DeleteProjectTargetCommand.java @@ -0,0 +1,6 @@ +package com.kntro.reqsai.integrations.application.command; + +import java.util.UUID; + +/** Delete a project's Jira push target. */ +public record DeleteProjectTargetCommand(UUID projectId, UUID requestedBy) {} diff --git a/src/main/java/com/kntro/reqsai/integrations/application/command/PushAllStoriesCommand.java b/src/main/java/com/kntro/reqsai/integrations/application/command/PushAllStoriesCommand.java new file mode 100644 index 00000000..4f5d2e72 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/integrations/application/command/PushAllStoriesCommand.java @@ -0,0 +1,6 @@ +package com.kntro.reqsai.integrations.application.command; + +import java.util.UUID; + +/** Push every story of a project to the project's configured Jira target (per-story failures captured). */ +public record PushAllStoriesCommand(UUID projectId, UUID requestedBy) {} diff --git a/src/main/java/com/kntro/reqsai/integrations/application/command/PushStoryCommand.java b/src/main/java/com/kntro/reqsai/integrations/application/command/PushStoryCommand.java new file mode 100644 index 00000000..9834b519 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/integrations/application/command/PushStoryCommand.java @@ -0,0 +1,6 @@ +package com.kntro.reqsai.integrations.application.command; + +import java.util.UUID; + +/** Push a single project story to the project's configured Jira target. */ +public record PushStoryCommand(UUID projectId, UUID storyId, UUID requestedBy) {} diff --git a/src/main/java/com/kntro/reqsai/integrations/application/command/SaveProjectTargetCommand.java b/src/main/java/com/kntro/reqsai/integrations/application/command/SaveProjectTargetCommand.java new file mode 100644 index 00000000..eff8a043 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/integrations/application/command/SaveProjectTargetCommand.java @@ -0,0 +1,12 @@ +package com.kntro.reqsai.integrations.application.command; + +import java.util.UUID; + +/** Create or replace the single Jira push target of a project. */ +public record SaveProjectTargetCommand( + UUID projectId, + UUID connectionId, + String jiraProjectKey, + String issueTypeName, + UUID requestedBy +) {} diff --git a/src/main/java/com/kntro/reqsai/integrations/application/handler/ConnectJiraCommandHandler.java b/src/main/java/com/kntro/reqsai/integrations/application/handler/ConnectJiraCommandHandler.java new file mode 100644 index 00000000..0373ff5b --- /dev/null +++ b/src/main/java/com/kntro/reqsai/integrations/application/handler/ConnectJiraCommandHandler.java @@ -0,0 +1,46 @@ +package com.kntro.reqsai.integrations.application.handler; + +import com.kntro.reqsai.integrations.application.command.ConnectJiraCommand; +import com.kntro.reqsai.integrations.application.port.IntegrationConnectionRepository; +import com.kntro.reqsai.integrations.application.port.IntegrationProvider; +import com.kntro.reqsai.integrations.application.service.ProviderRegistry; +import com.kntro.reqsai.integrations.domain.exception.IntegrationsExceptions; +import com.kntro.reqsai.integrations.domain.model.ConnectionStatus; +import com.kntro.reqsai.integrations.domain.model.IntegrationConnection; +import com.kntro.reqsai.integrations.domain.model.IntegrationProviderType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; + +/** + * Connects a Jira integration at the organization level: verifies the credential against Jira + * (fail → {@code JIRA_AUTH_FAILED}/{@code JIRA_UNREACHABLE}) and, on success, persists an encrypted + * connection. Rejects a second active connection with {@code INTEGRATION_ALREADY_CONNECTED}. + */ +@Component +@RequiredArgsConstructor +public class ConnectJiraCommandHandler { + + private final IntegrationConnectionRepository connections; + private final ProviderRegistry providers; + + @Transactional + public IntegrationConnection handle(ConnectJiraCommand command) { + if (connections.existsByOrganizationIdAndProviderAndStatusNot( + command.organizationId(), IntegrationProviderType.JIRA, ConnectionStatus.DISCONNECTED)) { + throw IntegrationsExceptions.alreadyConnected(command.organizationId(), IntegrationProviderType.JIRA.name()); + } + + String siteUrl = IntegrationConnection.normalizeSiteUrl(command.siteUrl()); + IntegrationProvider provider = providers.get(IntegrationProviderType.JIRA); + // Verify the credential BEFORE persisting anything. Throws on auth/reachability failure. + provider.verify(new IntegrationProvider.ProviderCredentials(siteUrl, command.email(), command.apiToken())); + + IntegrationConnection connection = new IntegrationConnection( + command.organizationId(), IntegrationProviderType.JIRA, + siteUrl, command.email(), command.apiToken(), Instant.now()); + return connections.save(connection); + } +} diff --git a/src/main/java/com/kntro/reqsai/integrations/application/handler/DeleteConnectionCommandHandler.java b/src/main/java/com/kntro/reqsai/integrations/application/handler/DeleteConnectionCommandHandler.java new file mode 100644 index 00000000..ef8ae752 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/integrations/application/handler/DeleteConnectionCommandHandler.java @@ -0,0 +1,28 @@ +package com.kntro.reqsai.integrations.application.handler; + +import com.kntro.reqsai.integrations.application.command.DeleteConnectionCommand; +import com.kntro.reqsai.integrations.application.port.IntegrationConnectionRepository; +import com.kntro.reqsai.integrations.domain.exception.IntegrationsExceptions; +import com.kntro.reqsai.integrations.domain.model.IntegrationConnection; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * Deletes an organization connection. Project targets referencing it are removed by the FK + * {@code ON DELETE CASCADE}. + */ +@Component +@RequiredArgsConstructor +public class DeleteConnectionCommandHandler { + + private final IntegrationConnectionRepository connections; + + @Transactional + public void handle(DeleteConnectionCommand command) { + IntegrationConnection connection = connections + .findByIdAndOrganizationId(command.connectionId(), command.organizationId()) + .orElseThrow(() -> IntegrationsExceptions.connectionNotFound(command.connectionId())); + connections.delete(connection); + } +} diff --git a/src/main/java/com/kntro/reqsai/integrations/application/handler/DeleteProjectTargetCommandHandler.java b/src/main/java/com/kntro/reqsai/integrations/application/handler/DeleteProjectTargetCommandHandler.java new file mode 100644 index 00000000..39a3e976 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/integrations/application/handler/DeleteProjectTargetCommandHandler.java @@ -0,0 +1,24 @@ +package com.kntro.reqsai.integrations.application.handler; + +import com.kntro.reqsai.integrations.application.command.DeleteProjectTargetCommand; +import com.kntro.reqsai.integrations.application.port.ProjectIntegrationTargetRepository; +import com.kntro.reqsai.integrations.domain.exception.IntegrationsExceptions; +import com.kntro.reqsai.integrations.domain.model.ProjectIntegrationTarget; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** Deletes a project's Jira target (404 when none is configured). */ +@Component +@RequiredArgsConstructor +public class DeleteProjectTargetCommandHandler { + + private final ProjectIntegrationTargetRepository targets; + + @Transactional + public void handle(DeleteProjectTargetCommand command) { + ProjectIntegrationTarget target = targets.findByProjectId(command.projectId()) + .orElseThrow(() -> IntegrationsExceptions.targetNotFound(command.projectId())); + targets.delete(target); + } +} diff --git a/src/main/java/com/kntro/reqsai/integrations/application/handler/GetProjectTargetQueryHandler.java b/src/main/java/com/kntro/reqsai/integrations/application/handler/GetProjectTargetQueryHandler.java new file mode 100644 index 00000000..0d4e5a3e --- /dev/null +++ b/src/main/java/com/kntro/reqsai/integrations/application/handler/GetProjectTargetQueryHandler.java @@ -0,0 +1,23 @@ +package com.kntro.reqsai.integrations.application.handler; + +import com.kntro.reqsai.integrations.application.port.ProjectIntegrationTargetRepository; +import com.kntro.reqsai.integrations.application.query.GetProjectTargetQuery; +import com.kntro.reqsai.integrations.domain.exception.IntegrationsExceptions; +import com.kntro.reqsai.integrations.domain.model.ProjectIntegrationTarget; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** Reads a project's Jira target, 404 ({@code INTEGRATION_CONNECTION_NOT_FOUND}) when none is set. */ +@Component +@RequiredArgsConstructor +public class GetProjectTargetQueryHandler { + + private final ProjectIntegrationTargetRepository targets; + + @Transactional(readOnly = true) + public ProjectIntegrationTarget handle(GetProjectTargetQuery query) { + return targets.findByProjectId(query.projectId()) + .orElseThrow(() -> IntegrationsExceptions.targetNotFound(query.projectId())); + } +} diff --git a/src/main/java/com/kntro/reqsai/integrations/application/handler/ListConnectionsQueryHandler.java b/src/main/java/com/kntro/reqsai/integrations/application/handler/ListConnectionsQueryHandler.java new file mode 100644 index 00000000..c6d1dde2 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/integrations/application/handler/ListConnectionsQueryHandler.java @@ -0,0 +1,23 @@ +package com.kntro.reqsai.integrations.application.handler; + +import com.kntro.reqsai.integrations.application.port.IntegrationConnectionRepository; +import com.kntro.reqsai.integrations.application.query.ListConnectionsQuery; +import com.kntro.reqsai.integrations.domain.model.IntegrationConnection; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** Lists an organization's integration connections (never exposing the token). */ +@Component +@RequiredArgsConstructor +public class ListConnectionsQueryHandler { + + private final IntegrationConnectionRepository connections; + + @Transactional(readOnly = true) + public List handle(ListConnectionsQuery query) { + return connections.findAllByOrganizationId(query.organizationId()); + } +} diff --git a/src/main/java/com/kntro/reqsai/integrations/application/handler/ListJiraIssueTypesQueryHandler.java b/src/main/java/com/kntro/reqsai/integrations/application/handler/ListJiraIssueTypesQueryHandler.java new file mode 100644 index 00000000..e24f5c84 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/integrations/application/handler/ListJiraIssueTypesQueryHandler.java @@ -0,0 +1,34 @@ +package com.kntro.reqsai.integrations.application.handler; + +import com.kntro.reqsai.integrations.application.port.IntegrationConnectionRepository; +import com.kntro.reqsai.integrations.application.port.IntegrationProvider; +import com.kntro.reqsai.integrations.application.port.IntegrationProvider.RemoteIssueType; +import com.kntro.reqsai.integrations.application.query.ListJiraIssueTypesQuery; +import com.kntro.reqsai.integrations.application.service.ProviderCredentialsFactory; +import com.kntro.reqsai.integrations.application.service.ProviderRegistry; +import com.kntro.reqsai.integrations.domain.exception.IntegrationsExceptions; +import com.kntro.reqsai.integrations.domain.model.IntegrationConnection; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** Lists the Jira issue types for a project key visible to a connection (live provider call). */ +@Component +@RequiredArgsConstructor +public class ListJiraIssueTypesQueryHandler { + + private final IntegrationConnectionRepository connections; + private final ProviderRegistry providers; + private final ProviderCredentialsFactory credentials; + + @Transactional(readOnly = true) + public List handle(ListJiraIssueTypesQuery query) { + IntegrationConnection connection = connections + .findByIdAndOrganizationId(query.connectionId(), query.organizationId()) + .orElseThrow(() -> IntegrationsExceptions.connectionNotFound(query.connectionId())); + IntegrationProvider provider = providers.get(connection.getProvider()); + return provider.listIssueTypes(credentials.from(connection), query.projectKey()); + } +} diff --git a/src/main/java/com/kntro/reqsai/integrations/application/handler/ListJiraProjectsQueryHandler.java b/src/main/java/com/kntro/reqsai/integrations/application/handler/ListJiraProjectsQueryHandler.java new file mode 100644 index 00000000..dec30e02 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/integrations/application/handler/ListJiraProjectsQueryHandler.java @@ -0,0 +1,34 @@ +package com.kntro.reqsai.integrations.application.handler; + +import com.kntro.reqsai.integrations.application.port.IntegrationConnectionRepository; +import com.kntro.reqsai.integrations.application.port.IntegrationProvider; +import com.kntro.reqsai.integrations.application.port.IntegrationProvider.RemoteProject; +import com.kntro.reqsai.integrations.application.query.ListJiraProjectsQuery; +import com.kntro.reqsai.integrations.application.service.ProviderCredentialsFactory; +import com.kntro.reqsai.integrations.application.service.ProviderRegistry; +import com.kntro.reqsai.integrations.domain.exception.IntegrationsExceptions; +import com.kntro.reqsai.integrations.domain.model.IntegrationConnection; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** Lists the Jira projects visible to a connection (live provider call). */ +@Component +@RequiredArgsConstructor +public class ListJiraProjectsQueryHandler { + + private final IntegrationConnectionRepository connections; + private final ProviderRegistry providers; + private final ProviderCredentialsFactory credentials; + + @Transactional(readOnly = true) + public List handle(ListJiraProjectsQuery query) { + IntegrationConnection connection = connections + .findByIdAndOrganizationId(query.connectionId(), query.organizationId()) + .orElseThrow(() -> IntegrationsExceptions.connectionNotFound(query.connectionId())); + IntegrationProvider provider = providers.get(connection.getProvider()); + return provider.listProjects(credentials.from(connection)); + } +} diff --git a/src/main/java/com/kntro/reqsai/integrations/application/handler/PushAllStoriesCommandHandler.java b/src/main/java/com/kntro/reqsai/integrations/application/handler/PushAllStoriesCommandHandler.java new file mode 100644 index 00000000..f0aa11e2 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/integrations/application/handler/PushAllStoriesCommandHandler.java @@ -0,0 +1,58 @@ +package com.kntro.reqsai.integrations.application.handler; + +import com.kntro.reqsai.discovery.api.DiscoveryStoryReadPort; +import com.kntro.reqsai.discovery.api.StoryView; +import com.kntro.reqsai.integrations.application.command.PushAllStoriesCommand; +import com.kntro.reqsai.integrations.application.port.IntegrationProvider.PushedIssue; +import com.kntro.reqsai.integrations.application.port.ProjectIntegrationTargetRepository; +import com.kntro.reqsai.integrations.application.result.BatchPushResult; +import com.kntro.reqsai.integrations.application.result.StoryPushResult; +import com.kntro.reqsai.integrations.application.service.StoryPushService; +import com.kntro.reqsai.integrations.application.service.StoryPushService.PushContext; +import com.kntro.reqsai.integrations.domain.exception.IntegrationsExceptions; +import com.kntro.reqsai.integrations.domain.model.ProjectIntegrationTarget; +import com.kntro.reqsai.shared.domain.exception.DomainException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; + +/** + * Pushes every story of a project to its Jira target, capturing per-story failures without + * aborting the batch: a failed push records the error code and the loop continues. 409 + * ({@code INTEGRATION_TARGET_NOT_CONFIGURED}) when no target exists. + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class PushAllStoriesCommandHandler { + + private final ProjectIntegrationTargetRepository targets; + private final DiscoveryStoryReadPort stories; + private final StoryPushService pushService; + + @Transactional(readOnly = true) + public BatchPushResult handle(PushAllStoriesCommand command) { + ProjectIntegrationTarget target = targets.findByProjectId(command.projectId()) + .orElseThrow(() -> IntegrationsExceptions.targetNotConfigured(command.projectId())); + + PushContext ctx = pushService.contextFor(target); + List all = stories.listStories(command.projectId()); + + List results = new ArrayList<>(all.size()); + for (StoryView story : all) { + try { + PushedIssue issue = pushService.push(ctx, story); + results.add(StoryPushResult.success(story.storyId(), issue.issueKey(), issue.issueUrl())); + } catch (DomainException e) { + // Infrastructure/domain failure on one story must not abort the rest of the batch. + log.warn("Push failed for story {} [{}]", story.storyId(), e.error().code()); + results.add(StoryPushResult.failure(story.storyId(), e.error().code())); + } + } + return BatchPushResult.of(results); + } +} diff --git a/src/main/java/com/kntro/reqsai/integrations/application/handler/PushStoryCommandHandler.java b/src/main/java/com/kntro/reqsai/integrations/application/handler/PushStoryCommandHandler.java new file mode 100644 index 00000000..cf76235c --- /dev/null +++ b/src/main/java/com/kntro/reqsai/integrations/application/handler/PushStoryCommandHandler.java @@ -0,0 +1,42 @@ +package com.kntro.reqsai.integrations.application.handler; + +import com.kntro.reqsai.discovery.api.DiscoveryStoryReadPort; +import com.kntro.reqsai.discovery.api.StoryView; +import com.kntro.reqsai.integrations.application.command.PushStoryCommand; +import com.kntro.reqsai.integrations.application.port.IntegrationProvider.PushedIssue; +import com.kntro.reqsai.integrations.application.port.ProjectIntegrationTargetRepository; +import com.kntro.reqsai.integrations.application.result.StoryPushResult; +import com.kntro.reqsai.integrations.application.service.StoryPushService; +import com.kntro.reqsai.integrations.application.service.StoryPushService.PushContext; +import com.kntro.reqsai.integrations.domain.exception.IntegrationsExceptions; +import com.kntro.reqsai.integrations.domain.model.ProjectIntegrationTarget; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * Pushes a single project story to the project's configured Jira target. 409 + * ({@code INTEGRATION_TARGET_NOT_CONFIGURED}) when no target exists; 404 when the story is not in the + * project; provider failures ({@code JIRA_*}) surface as infrastructure exceptions. + */ +@Component +@RequiredArgsConstructor +public class PushStoryCommandHandler { + + private final ProjectIntegrationTargetRepository targets; + private final DiscoveryStoryReadPort stories; + private final StoryPushService pushService; + + @Transactional(readOnly = true) + public StoryPushResult handle(PushStoryCommand command) { + ProjectIntegrationTarget target = targets.findByProjectId(command.projectId()) + .orElseThrow(() -> IntegrationsExceptions.targetNotConfigured(command.projectId())); + + StoryView story = stories.findStory(command.projectId(), command.storyId()) + .orElseThrow(() -> IntegrationsExceptions.storyNotFound(command.storyId())); + + PushContext ctx = pushService.contextFor(target); + PushedIssue issue = pushService.push(ctx, story); + return StoryPushResult.success(command.storyId(), issue.issueKey(), issue.issueUrl()); + } +} diff --git a/src/main/java/com/kntro/reqsai/integrations/application/handler/SaveProjectTargetCommandHandler.java b/src/main/java/com/kntro/reqsai/integrations/application/handler/SaveProjectTargetCommandHandler.java new file mode 100644 index 00000000..8e9b4270 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/integrations/application/handler/SaveProjectTargetCommandHandler.java @@ -0,0 +1,38 @@ +package com.kntro.reqsai.integrations.application.handler; + +import com.kntro.reqsai.integrations.application.command.SaveProjectTargetCommand; +import com.kntro.reqsai.integrations.application.port.IntegrationConnectionRepository; +import com.kntro.reqsai.integrations.application.port.ProjectIntegrationTargetRepository; +import com.kntro.reqsai.integrations.domain.exception.IntegrationsExceptions; +import com.kntro.reqsai.integrations.domain.model.ProjectIntegrationTarget; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * Creates or replaces the single Jira push target of a project (upsert). Validates the referenced + * connection exists in the tenant ({@code INTEGRATION_CONNECTION_NOT_FOUND} otherwise). + */ +@Component +@RequiredArgsConstructor +public class SaveProjectTargetCommandHandler { + + private final ProjectIntegrationTargetRepository targets; + private final IntegrationConnectionRepository connections; + + @Transactional + public ProjectIntegrationTarget handle(SaveProjectTargetCommand command) { + connections.findById(command.connectionId()) + .orElseThrow(() -> IntegrationsExceptions.connectionNotFound(command.connectionId())); + + ProjectIntegrationTarget target = targets.findByProjectId(command.projectId()) + .map(existing -> { + existing.update(command.connectionId(), command.jiraProjectKey(), command.issueTypeName()); + return existing; + }) + .orElseGet(() -> new ProjectIntegrationTarget( + command.projectId(), command.connectionId(), + command.jiraProjectKey(), command.issueTypeName())); + return targets.save(target); + } +} diff --git a/src/main/java/com/kntro/reqsai/integrations/application/handler/TestConnectionQueryHandler.java b/src/main/java/com/kntro/reqsai/integrations/application/handler/TestConnectionQueryHandler.java new file mode 100644 index 00000000..5f3f99e3 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/integrations/application/handler/TestConnectionQueryHandler.java @@ -0,0 +1,52 @@ +package com.kntro.reqsai.integrations.application.handler; + +import com.kntro.reqsai.integrations.application.port.IntegrationConnectionRepository; +import com.kntro.reqsai.integrations.application.port.IntegrationProvider; +import com.kntro.reqsai.integrations.application.query.TestConnectionQuery; +import com.kntro.reqsai.integrations.application.result.ConnectionTestResult; +import com.kntro.reqsai.integrations.application.service.ProviderCredentialsFactory; +import com.kntro.reqsai.integrations.application.service.ProviderRegistry; +import com.kntro.reqsai.integrations.domain.exception.IntegrationsExceptions; +import com.kntro.reqsai.integrations.domain.model.IntegrationConnection; +import com.kntro.reqsai.shared.domain.exception.InfrastructureException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; + +/** + * Re-verifies a connection's stored credential against the provider. Returns {@code ok=true} + the + * account name and marks the connection verified on success; on an auth/reachability failure it marks + * the connection {@code DEGRADED} and returns {@code ok=false} (a test never fails the request). + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class TestConnectionQueryHandler { + + private final IntegrationConnectionRepository connections; + private final ProviderRegistry providers; + private final ProviderCredentialsFactory credentials; + + @Transactional + public ConnectionTestResult handle(TestConnectionQuery query) { + IntegrationConnection connection = connections + .findByIdAndOrganizationId(query.connectionId(), query.organizationId()) + .orElseThrow(() -> IntegrationsExceptions.connectionNotFound(query.connectionId())); + + IntegrationProvider provider = providers.get(connection.getProvider()); + try { + String accountName = provider.verify(credentials.from(connection)); + connection.markVerified(Instant.now()); + connections.save(connection); + return new ConnectionTestResult(true, accountName); + } catch (InfrastructureException e) { + log.warn("Connection {} verification failed [{}]", connection.getId(), e.error().code()); + connection.markDegraded(); + connections.save(connection); + return new ConnectionTestResult(false, null); + } + } +} diff --git a/src/main/java/com/kntro/reqsai/integrations/application/port/IntegrationConnectionRepository.java b/src/main/java/com/kntro/reqsai/integrations/application/port/IntegrationConnectionRepository.java new file mode 100644 index 00000000..cbfe0d66 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/integrations/application/port/IntegrationConnectionRepository.java @@ -0,0 +1,26 @@ +package com.kntro.reqsai.integrations.application.port; + +import com.kntro.reqsai.integrations.domain.model.ConnectionStatus; +import com.kntro.reqsai.integrations.domain.model.IntegrationConnection; +import com.kntro.reqsai.integrations.domain.model.IntegrationProviderType; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +/** Persistence port for the {@link IntegrationConnection} aggregate. Tenant-scoped. */ +public interface IntegrationConnectionRepository { + + IntegrationConnection save(IntegrationConnection connection); + + Optional findById(UUID id); + + Optional findByIdAndOrganizationId(UUID id, UUID organizationId); + + List findAllByOrganizationId(UUID organizationId); + + boolean existsByOrganizationIdAndProviderAndStatusNot( + UUID organizationId, IntegrationProviderType provider, ConnectionStatus status); + + void delete(IntegrationConnection connection); +} diff --git a/src/main/java/com/kntro/reqsai/integrations/application/port/IntegrationProvider.java b/src/main/java/com/kntro/reqsai/integrations/application/port/IntegrationProvider.java new file mode 100644 index 00000000..557e7eeb --- /dev/null +++ b/src/main/java/com/kntro/reqsai/integrations/application/port/IntegrationProvider.java @@ -0,0 +1,45 @@ +package com.kntro.reqsai.integrations.application.port; + +import com.kntro.reqsai.discovery.api.StoryView; +import com.kntro.reqsai.integrations.domain.model.IntegrationProviderType; + +import java.util.List; + +/** + * Provider seam (ADR-0022): the capability of talking to a third-party tracker. Jira is the first + * implementation ({@code JiraProvider}); adding another provider means adding an implementation keyed + * by its {@link IntegrationProviderType}, with no change to the handlers or endpoints. + * + *

Credentials are passed explicitly (decrypted by the caller) so the provider never touches + * persistence. Failures surface as infrastructure exceptions + * ({@code JIRA_AUTH_FAILED} / {@code JIRA_UNREACHABLE} / {@code JIRA_PUSH_FAILED}). + */ +public interface IntegrationProvider { + + /** The provider this implementation serves. */ + IntegrationProviderType type(); + + /** Verifies credentials, returning the authenticated account's display name. */ + String verify(ProviderCredentials credentials); + + /** Lists the projects visible to the credentials. */ + List listProjects(ProviderCredentials credentials); + + /** Lists issue types available for the given project key. */ + List listIssueTypes(ProviderCredentials credentials, String projectKey); + + /** Creates a tracker issue from a Reqs-AI story and returns its key + browse URL. */ + PushedIssue pushStory(ProviderCredentials credentials, String projectKey, String issueTypeName, StoryView story); + + /** Decrypted credentials for a single provider call (never persisted, never logged). */ + record ProviderCredentials(String siteUrl, String email, String apiToken) {} + + /** A remote project ({key,name}). */ + record RemoteProject(String key, String name) {} + + /** A remote issue type ({id,name}). */ + record RemoteIssueType(String id, String name) {} + + /** The result of a successful push ({issueKey, issueUrl}). */ + record PushedIssue(String issueKey, String issueUrl) {} +} diff --git a/src/main/java/com/kntro/reqsai/integrations/application/port/ProjectIntegrationTargetRepository.java b/src/main/java/com/kntro/reqsai/integrations/application/port/ProjectIntegrationTargetRepository.java new file mode 100644 index 00000000..85a436de --- /dev/null +++ b/src/main/java/com/kntro/reqsai/integrations/application/port/ProjectIntegrationTargetRepository.java @@ -0,0 +1,16 @@ +package com.kntro.reqsai.integrations.application.port; + +import com.kntro.reqsai.integrations.domain.model.ProjectIntegrationTarget; + +import java.util.Optional; +import java.util.UUID; + +/** Persistence port for the {@link ProjectIntegrationTarget} aggregate. Tenant-scoped. */ +public interface ProjectIntegrationTargetRepository { + + ProjectIntegrationTarget save(ProjectIntegrationTarget target); + + Optional findByProjectId(UUID projectId); + + void delete(ProjectIntegrationTarget target); +} diff --git a/src/main/java/com/kntro/reqsai/integrations/application/query/GetProjectTargetQuery.java b/src/main/java/com/kntro/reqsai/integrations/application/query/GetProjectTargetQuery.java new file mode 100644 index 00000000..beba29e2 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/integrations/application/query/GetProjectTargetQuery.java @@ -0,0 +1,6 @@ +package com.kntro.reqsai.integrations.application.query; + +import java.util.UUID; + +/** Read a project's Jira push target. */ +public record GetProjectTargetQuery(UUID projectId, UUID requestedBy) {} diff --git a/src/main/java/com/kntro/reqsai/integrations/application/query/ListConnectionsQuery.java b/src/main/java/com/kntro/reqsai/integrations/application/query/ListConnectionsQuery.java new file mode 100644 index 00000000..b2767180 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/integrations/application/query/ListConnectionsQuery.java @@ -0,0 +1,6 @@ +package com.kntro.reqsai.integrations.application.query; + +import java.util.UUID; + +/** List an organization's integration connections. */ +public record ListConnectionsQuery(UUID organizationId, UUID requestedBy) {} diff --git a/src/main/java/com/kntro/reqsai/integrations/application/query/ListJiraIssueTypesQuery.java b/src/main/java/com/kntro/reqsai/integrations/application/query/ListJiraIssueTypesQuery.java new file mode 100644 index 00000000..0cb92586 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/integrations/application/query/ListJiraIssueTypesQuery.java @@ -0,0 +1,6 @@ +package com.kntro.reqsai.integrations.application.query; + +import java.util.UUID; + +/** List the Jira issue types for a project key visible to an organization connection. */ +public record ListJiraIssueTypesQuery(UUID organizationId, UUID connectionId, String projectKey, UUID requestedBy) {} diff --git a/src/main/java/com/kntro/reqsai/integrations/application/query/ListJiraProjectsQuery.java b/src/main/java/com/kntro/reqsai/integrations/application/query/ListJiraProjectsQuery.java new file mode 100644 index 00000000..9ab3549e --- /dev/null +++ b/src/main/java/com/kntro/reqsai/integrations/application/query/ListJiraProjectsQuery.java @@ -0,0 +1,6 @@ +package com.kntro.reqsai.integrations.application.query; + +import java.util.UUID; + +/** List the Jira projects visible to an organization connection. */ +public record ListJiraProjectsQuery(UUID organizationId, UUID connectionId, UUID requestedBy) {} diff --git a/src/main/java/com/kntro/reqsai/integrations/application/query/TestConnectionQuery.java b/src/main/java/com/kntro/reqsai/integrations/application/query/TestConnectionQuery.java new file mode 100644 index 00000000..e0369bf4 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/integrations/application/query/TestConnectionQuery.java @@ -0,0 +1,6 @@ +package com.kntro.reqsai.integrations.application.query; + +import java.util.UUID; + +/** Re-verify an organization connection's credential against the provider. */ +public record TestConnectionQuery(UUID organizationId, UUID connectionId, UUID requestedBy) {} diff --git a/src/main/java/com/kntro/reqsai/integrations/application/result/BatchPushResult.java b/src/main/java/com/kntro/reqsai/integrations/application/result/BatchPushResult.java new file mode 100644 index 00000000..5408dd2b --- /dev/null +++ b/src/main/java/com/kntro/reqsai/integrations/application/result/BatchPushResult.java @@ -0,0 +1,12 @@ +package com.kntro.reqsai.integrations.application.result; + +import java.util.List; + +/** Aggregate result of a push-all: per-story results plus pushed/failed counts. */ +public record BatchPushResult(List results, int pushed, int failed) { + + public static BatchPushResult of(List results) { + int pushed = (int) results.stream().filter(StoryPushResult::isSuccess).count(); + return new BatchPushResult(results, pushed, results.size() - pushed); + } +} diff --git a/src/main/java/com/kntro/reqsai/integrations/application/result/ConnectionTestResult.java b/src/main/java/com/kntro/reqsai/integrations/application/result/ConnectionTestResult.java new file mode 100644 index 00000000..c2d644e7 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/integrations/application/result/ConnectionTestResult.java @@ -0,0 +1,6 @@ +package com.kntro.reqsai.integrations.application.result; + +import org.jspecify.annotations.Nullable; + +/** Outcome of re-verifying a connection: {@code ok} plus the provider account name when successful. */ +public record ConnectionTestResult(boolean ok, @Nullable String accountName) {} diff --git a/src/main/java/com/kntro/reqsai/integrations/application/result/StoryPushResult.java b/src/main/java/com/kntro/reqsai/integrations/application/result/StoryPushResult.java new file mode 100644 index 00000000..5ee2ac15 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/integrations/application/result/StoryPushResult.java @@ -0,0 +1,29 @@ +package com.kntro.reqsai.integrations.application.result; + +import org.jspecify.annotations.Nullable; + +import java.util.UUID; + +/** + * Result of pushing a single story. On success {@code jiraIssueKey}/{@code jiraIssueUrl} are set and + * {@code error} is null; on failure (in a batch push) {@code error} carries the error code and the Jira + * fields are null. + */ +public record StoryPushResult( + UUID storyId, + @Nullable String jiraIssueKey, + @Nullable String jiraIssueUrl, + @Nullable String error +) { + public static StoryPushResult success(UUID storyId, String key, String url) { + return new StoryPushResult(storyId, key, url, null); + } + + public static StoryPushResult failure(UUID storyId, String errorCode) { + return new StoryPushResult(storyId, null, null, errorCode); + } + + public boolean isSuccess() { + return error == null; + } +} diff --git a/src/main/java/com/kntro/reqsai/integrations/application/service/ProviderCredentialsFactory.java b/src/main/java/com/kntro/reqsai/integrations/application/service/ProviderCredentialsFactory.java new file mode 100644 index 00000000..e1ae6310 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/integrations/application/service/ProviderCredentialsFactory.java @@ -0,0 +1,18 @@ +package com.kntro.reqsai.integrations.application.service; + +import com.kntro.reqsai.integrations.application.port.IntegrationProvider.ProviderCredentials; +import com.kntro.reqsai.integrations.domain.model.IntegrationConnection; +import org.springframework.stereotype.Component; + +/** + * Builds provider {@link ProviderCredentials} from a persisted {@link IntegrationConnection}, decrypting + * the token (the {@code apiToken} getter returns the decrypted value via the JPA converter). Isolated so + * the decryption point is single and obvious; the result is short-lived and never logged. + */ +@Component +public class ProviderCredentialsFactory { + + public ProviderCredentials from(IntegrationConnection connection) { + return new ProviderCredentials(connection.getSiteUrl(), connection.getEmail(), connection.getApiToken()); + } +} diff --git a/src/main/java/com/kntro/reqsai/integrations/application/service/ProviderRegistry.java b/src/main/java/com/kntro/reqsai/integrations/application/service/ProviderRegistry.java new file mode 100644 index 00000000..1c357e67 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/integrations/application/service/ProviderRegistry.java @@ -0,0 +1,33 @@ +package com.kntro.reqsai.integrations.application.service; + +import com.kntro.reqsai.integrations.application.port.IntegrationProvider; +import com.kntro.reqsai.integrations.domain.model.IntegrationProviderType; +import org.springframework.stereotype.Component; + +import java.util.EnumMap; +import java.util.List; +import java.util.Map; + +/** + * Resolves the {@link IntegrationProvider} for a given {@link IntegrationProviderType} (ADR-0022 provider + * seam). Indexes every provider bean by its {@code type()}; adding a provider is purely additive. + */ +@Component +public class ProviderRegistry { + + private final Map byType = + new EnumMap<>(IntegrationProviderType.class); + + public ProviderRegistry(List providers) { + providers.forEach(p -> byType.put(p.type(), p)); + } + + /** Returns the provider for {@code type}, or throws if none is registered. */ + public IntegrationProvider get(IntegrationProviderType type) { + IntegrationProvider provider = byType.get(type); + if (provider == null) { + throw new IllegalStateException("No integration provider registered for " + type); + } + return provider; + } +} diff --git a/src/main/java/com/kntro/reqsai/integrations/application/service/StoryPushService.java b/src/main/java/com/kntro/reqsai/integrations/application/service/StoryPushService.java new file mode 100644 index 00000000..9e91379f --- /dev/null +++ b/src/main/java/com/kntro/reqsai/integrations/application/service/StoryPushService.java @@ -0,0 +1,44 @@ +package com.kntro.reqsai.integrations.application.service; + +import com.kntro.reqsai.discovery.api.StoryView; +import com.kntro.reqsai.integrations.application.port.IntegrationConnectionRepository; +import com.kntro.reqsai.integrations.application.port.IntegrationProvider; +import com.kntro.reqsai.integrations.application.port.IntegrationProvider.ProviderCredentials; +import com.kntro.reqsai.integrations.application.port.IntegrationProvider.PushedIssue; +import com.kntro.reqsai.integrations.domain.exception.IntegrationsExceptions; +import com.kntro.reqsai.integrations.domain.model.IntegrationConnection; +import com.kntro.reqsai.integrations.domain.model.ProjectIntegrationTarget; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/** + * Shared push mechanics used by both the single-story and push-all handlers: resolves the target's + * connection + provider once, then pushes a story through the provider. Keeps the two handlers thin and + * their credential/provider resolution identical. + */ +@Component +@RequiredArgsConstructor +public class StoryPushService { + + private final IntegrationConnectionRepository connections; + private final ProviderRegistry providers; + private final ProviderCredentialsFactory credentialsFactory; + + /** Resolves the connection + provider for a target, or throws if the connection has gone missing. */ + public PushContext contextFor(ProjectIntegrationTarget target) { + IntegrationConnection connection = connections.findById(target.getConnectionId()) + .orElseThrow(() -> IntegrationsExceptions.connectionNotFound(target.getConnectionId())); + IntegrationProvider provider = providers.get(connection.getProvider()); + return new PushContext(provider, credentialsFactory.from(connection), + target.getJiraProjectKey(), target.getIssueTypeName()); + } + + /** Pushes one story within a resolved context. Throws an infrastructure exception on provider failure. */ + public PushedIssue push(PushContext ctx, StoryView story) { + return ctx.provider().pushStory(ctx.credentials(), ctx.projectKey(), ctx.issueTypeName(), story); + } + + /** Resolved-once push context for a project's target. */ + public record PushContext(IntegrationProvider provider, ProviderCredentials credentials, + String projectKey, String issueTypeName) {} +} From efa3b235ff0913c841f790ca2de4bda0c82cf3a1 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Mon, 6 Jul 2026 19:24:08 -0500 Subject: [PATCH 21/72] feat(integrations): add rest endpoints for jira connections, targets and story push --- ...OrganizationIntegrationControllerImpl.java | 107 +++++++++++++++ .../ProjectIntegrationControllerImpl.java | 82 ++++++++++++ .../rest/dto/request/ConnectJiraRequest.java | 21 +++ .../dto/request/SaveProjectTargetRequest.java | 23 ++++ .../rest/dto/response/BatchPushResponse.java | 9 ++ .../dto/response/ConnectionTestResponse.java | 8 ++ .../IntegrationConnectionResponse.java | 21 +++ .../dto/response/JiraIssueTypeResponse.java | 7 + .../dto/response/JiraProjectResponse.java | 7 + .../dto/response/JiraPushResultResponse.java | 15 +++ .../response/ProjectJiraTargetResponse.java | 18 +++ .../request/IntegrationRequestMapper.java | 25 ++++ .../response/IntegrationResponseMapper.java | 71 ++++++++++ .../OrganizationIntegrationController.java | 123 ++++++++++++++++++ .../swagger/ProjectIntegrationController.java | 108 +++++++++++++++ .../architecture/ArchitectureTests.java | 6 +- 16 files changed, 649 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/kntro/reqsai/integrations/interfaces/rest/controllers/OrganizationIntegrationControllerImpl.java create mode 100644 src/main/java/com/kntro/reqsai/integrations/interfaces/rest/controllers/ProjectIntegrationControllerImpl.java create mode 100644 src/main/java/com/kntro/reqsai/integrations/interfaces/rest/dto/request/ConnectJiraRequest.java create mode 100644 src/main/java/com/kntro/reqsai/integrations/interfaces/rest/dto/request/SaveProjectTargetRequest.java create mode 100644 src/main/java/com/kntro/reqsai/integrations/interfaces/rest/dto/response/BatchPushResponse.java create mode 100644 src/main/java/com/kntro/reqsai/integrations/interfaces/rest/dto/response/ConnectionTestResponse.java create mode 100644 src/main/java/com/kntro/reqsai/integrations/interfaces/rest/dto/response/IntegrationConnectionResponse.java create mode 100644 src/main/java/com/kntro/reqsai/integrations/interfaces/rest/dto/response/JiraIssueTypeResponse.java create mode 100644 src/main/java/com/kntro/reqsai/integrations/interfaces/rest/dto/response/JiraProjectResponse.java create mode 100644 src/main/java/com/kntro/reqsai/integrations/interfaces/rest/dto/response/JiraPushResultResponse.java create mode 100644 src/main/java/com/kntro/reqsai/integrations/interfaces/rest/dto/response/ProjectJiraTargetResponse.java create mode 100644 src/main/java/com/kntro/reqsai/integrations/interfaces/rest/mappers/request/IntegrationRequestMapper.java create mode 100644 src/main/java/com/kntro/reqsai/integrations/interfaces/rest/mappers/response/IntegrationResponseMapper.java create mode 100644 src/main/java/com/kntro/reqsai/integrations/interfaces/rest/swagger/OrganizationIntegrationController.java create mode 100644 src/main/java/com/kntro/reqsai/integrations/interfaces/rest/swagger/ProjectIntegrationController.java diff --git a/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/controllers/OrganizationIntegrationControllerImpl.java b/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/controllers/OrganizationIntegrationControllerImpl.java new file mode 100644 index 00000000..d0f8e622 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/controllers/OrganizationIntegrationControllerImpl.java @@ -0,0 +1,107 @@ +package com.kntro.reqsai.integrations.interfaces.rest.controllers; + +import com.kntro.reqsai.integrations.application.handler.ConnectJiraCommandHandler; +import com.kntro.reqsai.integrations.application.handler.DeleteConnectionCommandHandler; +import com.kntro.reqsai.integrations.application.handler.ListConnectionsQueryHandler; +import com.kntro.reqsai.integrations.application.handler.ListJiraIssueTypesQueryHandler; +import com.kntro.reqsai.integrations.application.handler.ListJiraProjectsQueryHandler; +import com.kntro.reqsai.integrations.application.handler.TestConnectionQueryHandler; +import com.kntro.reqsai.integrations.application.command.DeleteConnectionCommand; +import com.kntro.reqsai.integrations.application.query.ListConnectionsQuery; +import com.kntro.reqsai.integrations.application.query.ListJiraIssueTypesQuery; +import com.kntro.reqsai.integrations.application.query.ListJiraProjectsQuery; +import com.kntro.reqsai.integrations.application.query.TestConnectionQuery; +import com.kntro.reqsai.integrations.domain.model.IntegrationConnection; +import com.kntro.reqsai.integrations.interfaces.rest.dto.request.ConnectJiraRequest; +import com.kntro.reqsai.integrations.interfaces.rest.dto.response.ConnectionTestResponse; +import com.kntro.reqsai.integrations.interfaces.rest.dto.response.IntegrationConnectionResponse; +import com.kntro.reqsai.integrations.interfaces.rest.dto.response.JiraIssueTypeResponse; +import com.kntro.reqsai.integrations.interfaces.rest.dto.response.JiraProjectResponse; +import com.kntro.reqsai.integrations.interfaces.rest.mappers.request.IntegrationRequestMapper; +import com.kntro.reqsai.integrations.interfaces.rest.mappers.response.IntegrationResponseMapper; +import com.kntro.reqsai.integrations.interfaces.rest.swagger.OrganizationIntegrationController; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; + +import java.net.URI; +import java.util.List; +import java.util.UUID; + +/** + * Organization-level integration endpoints. Administering an org-wide credential is an org-admin action, + * so every method is gated by {@code @authz.orgOwnerOrAdmin} (ADR-0022). + */ +@RestController +@RequiredArgsConstructor +public class OrganizationIntegrationControllerImpl implements OrganizationIntegrationController { + + private final ListConnectionsQueryHandler listConnections; + private final ConnectJiraCommandHandler connectJira; + private final TestConnectionQueryHandler testConnection; + private final DeleteConnectionCommandHandler deleteConnection; + private final ListJiraProjectsQueryHandler listJiraProjects; + private final ListJiraIssueTypesQueryHandler listJiraIssueTypes; + + @Override + @PreAuthorize("@authz.orgOwnerOrAdmin(#orgId, authentication)") + public ResponseEntity> listConnections(UUID orgId, Authentication authentication) { + UUID requestedBy = UUID.fromString(authentication.getName()); + List body = listConnections.handle(new ListConnectionsQuery(orgId, requestedBy)) + .stream().map(IntegrationResponseMapper::toResponse).toList(); + return ResponseEntity.ok(body); + } + + @Override + @PreAuthorize("@authz.orgOwnerOrAdmin(#orgId, authentication)") + public ResponseEntity connectJira( + UUID orgId, ConnectJiraRequest request, Authentication authentication) { + UUID requestedBy = UUID.fromString(authentication.getName()); + IntegrationConnection connection = connectJira.handle( + IntegrationRequestMapper.toCommand(orgId, request, requestedBy)); + URI location = ServletUriComponentsBuilder.fromCurrentRequest() + .replacePath("/api/organizations/{orgId}/integrations/{id}") + .buildAndExpand(orgId, connection.getId()) + .toUri(); + return ResponseEntity.created(location).body(IntegrationResponseMapper.toResponse(connection)); + } + + @Override + @PreAuthorize("@authz.orgOwnerOrAdmin(#orgId, authentication)") + public ResponseEntity testConnection(UUID orgId, UUID connectionId, Authentication authentication) { + UUID requestedBy = UUID.fromString(authentication.getName()); + return ResponseEntity.ok(IntegrationResponseMapper.toResponse( + testConnection.handle(new TestConnectionQuery(orgId, connectionId, requestedBy)))); + } + + @Override + @PreAuthorize("@authz.orgOwnerOrAdmin(#orgId, authentication)") + public ResponseEntity deleteConnection(UUID orgId, UUID connectionId, Authentication authentication) { + UUID requestedBy = UUID.fromString(authentication.getName()); + deleteConnection.handle(new DeleteConnectionCommand(orgId, connectionId, requestedBy)); + return ResponseEntity.noContent().build(); + } + + @Override + @PreAuthorize("@authz.orgOwnerOrAdmin(#orgId, authentication)") + public ResponseEntity> listJiraProjects(UUID orgId, UUID connectionId, Authentication authentication) { + UUID requestedBy = UUID.fromString(authentication.getName()); + List body = listJiraProjects.handle(new ListJiraProjectsQuery(orgId, connectionId, requestedBy)) + .stream().map(IntegrationResponseMapper::toResponse).toList(); + return ResponseEntity.ok(body); + } + + @Override + @PreAuthorize("@authz.orgOwnerOrAdmin(#orgId, authentication)") + public ResponseEntity> listJiraIssueTypes( + UUID orgId, UUID connectionId, String projectKey, Authentication authentication) { + UUID requestedBy = UUID.fromString(authentication.getName()); + List body = listJiraIssueTypes + .handle(new ListJiraIssueTypesQuery(orgId, connectionId, projectKey, requestedBy)) + .stream().map(IntegrationResponseMapper::toResponse).toList(); + return ResponseEntity.ok(body); + } +} diff --git a/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/controllers/ProjectIntegrationControllerImpl.java b/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/controllers/ProjectIntegrationControllerImpl.java new file mode 100644 index 00000000..94daf4b7 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/controllers/ProjectIntegrationControllerImpl.java @@ -0,0 +1,82 @@ +package com.kntro.reqsai.integrations.interfaces.rest.controllers; + +import com.kntro.reqsai.integrations.application.command.DeleteProjectTargetCommand; +import com.kntro.reqsai.integrations.application.command.PushAllStoriesCommand; +import com.kntro.reqsai.integrations.application.command.PushStoryCommand; +import com.kntro.reqsai.integrations.application.handler.DeleteProjectTargetCommandHandler; +import com.kntro.reqsai.integrations.application.handler.GetProjectTargetQueryHandler; +import com.kntro.reqsai.integrations.application.handler.PushAllStoriesCommandHandler; +import com.kntro.reqsai.integrations.application.handler.PushStoryCommandHandler; +import com.kntro.reqsai.integrations.application.handler.SaveProjectTargetCommandHandler; +import com.kntro.reqsai.integrations.application.query.GetProjectTargetQuery; +import com.kntro.reqsai.integrations.interfaces.rest.dto.request.SaveProjectTargetRequest; +import com.kntro.reqsai.integrations.interfaces.rest.dto.response.BatchPushResponse; +import com.kntro.reqsai.integrations.interfaces.rest.dto.response.JiraPushResultResponse; +import com.kntro.reqsai.integrations.interfaces.rest.dto.response.ProjectJiraTargetResponse; +import com.kntro.reqsai.integrations.interfaces.rest.mappers.request.IntegrationRequestMapper; +import com.kntro.reqsai.integrations.interfaces.rest.mappers.response.IntegrationResponseMapper; +import com.kntro.reqsai.integrations.interfaces.rest.swagger.ProjectIntegrationController; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.RestController; + +import java.util.UUID; + +/** + * Project-level integration endpoints. Target read/write/delete are gated by project + * {@code INTEGRATION_WRITE}; story pushes by {@code INTEGRATION_SYNC}, via the tenant-bound + * {@code @authz.projectPermission} variant (these routes carry no {@code orgId}). + */ +@RestController +@RequiredArgsConstructor +public class ProjectIntegrationControllerImpl implements ProjectIntegrationController { + + private final GetProjectTargetQueryHandler getTarget; + private final SaveProjectTargetCommandHandler saveTarget; + private final DeleteProjectTargetCommandHandler deleteTarget; + private final PushStoryCommandHandler pushStory; + private final PushAllStoriesCommandHandler pushAllStories; + + @Override + @PreAuthorize("@authz.projectPermission(#projectId, 'INTEGRATION_READ', authentication)") + public ResponseEntity getTarget(UUID projectId, Authentication authentication) { + UUID requestedBy = UUID.fromString(authentication.getName()); + return ResponseEntity.ok(IntegrationResponseMapper.toResponse( + getTarget.handle(new GetProjectTargetQuery(projectId, requestedBy)))); + } + + @Override + @PreAuthorize("@authz.projectPermission(#projectId, 'INTEGRATION_WRITE', authentication)") + public ResponseEntity saveTarget( + UUID projectId, SaveProjectTargetRequest request, Authentication authentication) { + UUID requestedBy = UUID.fromString(authentication.getName()); + return ResponseEntity.ok(IntegrationResponseMapper.toResponse( + saveTarget.handle(IntegrationRequestMapper.toCommand(projectId, request, requestedBy)))); + } + + @Override + @PreAuthorize("@authz.projectPermission(#projectId, 'INTEGRATION_DELETE', authentication)") + public ResponseEntity deleteTarget(UUID projectId, Authentication authentication) { + UUID requestedBy = UUID.fromString(authentication.getName()); + deleteTarget.handle(new DeleteProjectTargetCommand(projectId, requestedBy)); + return ResponseEntity.noContent().build(); + } + + @Override + @PreAuthorize("@authz.projectPermission(#projectId, 'INTEGRATION_SYNC', authentication)") + public ResponseEntity pushStory(UUID projectId, UUID storyId, Authentication authentication) { + UUID requestedBy = UUID.fromString(authentication.getName()); + return ResponseEntity.ok(IntegrationResponseMapper.toResponse( + pushStory.handle(new PushStoryCommand(projectId, storyId, requestedBy)))); + } + + @Override + @PreAuthorize("@authz.projectPermission(#projectId, 'INTEGRATION_SYNC', authentication)") + public ResponseEntity pushAllStories(UUID projectId, Authentication authentication) { + UUID requestedBy = UUID.fromString(authentication.getName()); + return ResponseEntity.ok(IntegrationResponseMapper.toResponse( + pushAllStories.handle(new PushAllStoriesCommand(projectId, requestedBy)))); + } +} diff --git a/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/dto/request/ConnectJiraRequest.java b/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/dto/request/ConnectJiraRequest.java new file mode 100644 index 00000000..098a212c --- /dev/null +++ b/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/dto/request/ConnectJiraRequest.java @@ -0,0 +1,21 @@ +package com.kntro.reqsai.integrations.interfaces.rest.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +@Schema(description = "Request body to connect a Jira Cloud integration at the organization level") +public record ConnectJiraRequest( + @Schema(description = "Jira site base URL", example = "https://acme.atlassian.net", maxLength = 500) + @NotBlank @Size(max = 500) + String siteUrl, + + @Schema(description = "Jira account email", example = "pm@acme.com", maxLength = 320) + @NotBlank @Email @Size(max = 320) + String email, + + @Schema(description = "Jira API token (stored encrypted, never returned)") + @NotBlank + String apiToken +) {} diff --git a/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/dto/request/SaveProjectTargetRequest.java b/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/dto/request/SaveProjectTargetRequest.java new file mode 100644 index 00000000..257e55cb --- /dev/null +++ b/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/dto/request/SaveProjectTargetRequest.java @@ -0,0 +1,23 @@ +package com.kntro.reqsai.integrations.interfaces.rest.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.util.UUID; + +@Schema(description = "Request body to set a project's Jira push target") +public record SaveProjectTargetRequest( + @Schema(description = "Organization integration connection id") + @NotNull + UUID connectionId, + + @Schema(description = "Jira project key", example = "PAY", maxLength = 100) + @NotBlank @Size(max = 100) + String jiraProjectKey, + + @Schema(description = "Jira issue type name", example = "Story", maxLength = 100) + @NotBlank @Size(max = 100) + String issueTypeName +) {} diff --git a/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/dto/response/BatchPushResponse.java b/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/dto/response/BatchPushResponse.java new file mode 100644 index 00000000..24e7d267 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/dto/response/BatchPushResponse.java @@ -0,0 +1,9 @@ +package com.kntro.reqsai.integrations.interfaces.rest.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +/** Aggregate result of a push-all: per-story results plus pushed/failed counts. */ +@Schema(description = "Result of pushing all project stories to Jira") +public record BatchPushResponse(List results, int pushed, int failed) {} diff --git a/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/dto/response/ConnectionTestResponse.java b/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/dto/response/ConnectionTestResponse.java new file mode 100644 index 00000000..c87021ca --- /dev/null +++ b/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/dto/response/ConnectionTestResponse.java @@ -0,0 +1,8 @@ +package com.kntro.reqsai.integrations.interfaces.rest.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import org.jspecify.annotations.Nullable; + +/** Result of testing a connection: {@code ok} plus the account display name when successful. */ +@Schema(description = "Connection test result") +public record ConnectionTestResponse(boolean ok, @Nullable String accountName) {} diff --git a/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/dto/response/IntegrationConnectionResponse.java b/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/dto/response/IntegrationConnectionResponse.java new file mode 100644 index 00000000..d9d126df --- /dev/null +++ b/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/dto/response/IntegrationConnectionResponse.java @@ -0,0 +1,21 @@ +package com.kntro.reqsai.integrations.interfaces.rest.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import org.jspecify.annotations.Nullable; + +import java.time.Instant; +import java.util.UUID; + +/** Organization integration connection resource. NEVER carries the API token. */ +@Schema(description = "Organization integration connection (the API token is never returned)") +public record IntegrationConnectionResponse( + UUID id, + UUID organizationId, + String provider, + String siteUrl, + String email, + String status, + @Nullable Instant lastVerifiedAt, + Instant createdAt, + Instant updatedAt +) {} diff --git a/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/dto/response/JiraIssueTypeResponse.java b/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/dto/response/JiraIssueTypeResponse.java new file mode 100644 index 00000000..60d47bf1 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/dto/response/JiraIssueTypeResponse.java @@ -0,0 +1,7 @@ +package com.kntro.reqsai.integrations.interfaces.rest.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** A selectable Jira issue type. */ +@Schema(description = "A Jira issue type available for a project") +public record JiraIssueTypeResponse(String id, String name) {} diff --git a/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/dto/response/JiraProjectResponse.java b/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/dto/response/JiraProjectResponse.java new file mode 100644 index 00000000..37a7e6bf --- /dev/null +++ b/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/dto/response/JiraProjectResponse.java @@ -0,0 +1,7 @@ +package com.kntro.reqsai.integrations.interfaces.rest.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** A selectable Jira project. */ +@Schema(description = "A Jira project visible to the connection") +public record JiraProjectResponse(String key, String name) {} diff --git a/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/dto/response/JiraPushResultResponse.java b/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/dto/response/JiraPushResultResponse.java new file mode 100644 index 00000000..4e590191 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/dto/response/JiraPushResultResponse.java @@ -0,0 +1,15 @@ +package com.kntro.reqsai.integrations.interfaces.rest.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import org.jspecify.annotations.Nullable; + +import java.util.UUID; + +/** Result of pushing one story to Jira. {@code error} is set (and the Jira fields null) on failure. */ +@Schema(description = "Result of pushing a single story to Jira") +public record JiraPushResultResponse( + UUID storyId, + @Nullable String jiraIssueKey, + @Nullable String jiraIssueUrl, + @Nullable String error +) {} diff --git a/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/dto/response/ProjectJiraTargetResponse.java b/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/dto/response/ProjectJiraTargetResponse.java new file mode 100644 index 00000000..5e432294 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/dto/response/ProjectJiraTargetResponse.java @@ -0,0 +1,18 @@ +package com.kntro.reqsai.integrations.interfaces.rest.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.Instant; +import java.util.UUID; + +/** A project's Jira push target. */ +@Schema(description = "The Jira push target configured for a project") +public record ProjectJiraTargetResponse( + UUID id, + UUID projectId, + UUID connectionId, + String jiraProjectKey, + String issueTypeName, + Instant createdAt, + Instant updatedAt +) {} diff --git a/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/mappers/request/IntegrationRequestMapper.java b/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/mappers/request/IntegrationRequestMapper.java new file mode 100644 index 00000000..7ec3e4cb --- /dev/null +++ b/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/mappers/request/IntegrationRequestMapper.java @@ -0,0 +1,25 @@ +package com.kntro.reqsai.integrations.interfaces.rest.mappers.request; + +import com.kntro.reqsai.integrations.application.command.ConnectJiraCommand; +import com.kntro.reqsai.integrations.application.command.SaveProjectTargetCommand; +import com.kntro.reqsai.integrations.interfaces.rest.dto.request.ConnectJiraRequest; +import com.kntro.reqsai.integrations.interfaces.rest.dto.request.SaveProjectTargetRequest; + +import java.util.UUID; + +/** Maps integration REST requests to application commands. */ +public final class IntegrationRequestMapper { + + private IntegrationRequestMapper() { + throw new UnsupportedOperationException("Utility class - do not instantiate"); + } + + public static ConnectJiraCommand toCommand(UUID orgId, ConnectJiraRequest request, UUID requestedBy) { + return new ConnectJiraCommand(orgId, request.siteUrl(), request.email(), request.apiToken(), requestedBy); + } + + public static SaveProjectTargetCommand toCommand(UUID projectId, SaveProjectTargetRequest request, UUID requestedBy) { + return new SaveProjectTargetCommand( + projectId, request.connectionId(), request.jiraProjectKey(), request.issueTypeName(), requestedBy); + } +} diff --git a/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/mappers/response/IntegrationResponseMapper.java b/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/mappers/response/IntegrationResponseMapper.java new file mode 100644 index 00000000..8b2395cf --- /dev/null +++ b/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/mappers/response/IntegrationResponseMapper.java @@ -0,0 +1,71 @@ +package com.kntro.reqsai.integrations.interfaces.rest.mappers.response; + +import com.kntro.reqsai.integrations.application.port.IntegrationProvider.RemoteIssueType; +import com.kntro.reqsai.integrations.application.port.IntegrationProvider.RemoteProject; +import com.kntro.reqsai.integrations.application.result.BatchPushResult; +import com.kntro.reqsai.integrations.application.result.ConnectionTestResult; +import com.kntro.reqsai.integrations.application.result.StoryPushResult; +import com.kntro.reqsai.integrations.domain.model.IntegrationConnection; +import com.kntro.reqsai.integrations.domain.model.ProjectIntegrationTarget; +import com.kntro.reqsai.integrations.interfaces.rest.dto.response.BatchPushResponse; +import com.kntro.reqsai.integrations.interfaces.rest.dto.response.ConnectionTestResponse; +import com.kntro.reqsai.integrations.interfaces.rest.dto.response.IntegrationConnectionResponse; +import com.kntro.reqsai.integrations.interfaces.rest.dto.response.JiraIssueTypeResponse; +import com.kntro.reqsai.integrations.interfaces.rest.dto.response.JiraProjectResponse; +import com.kntro.reqsai.integrations.interfaces.rest.dto.response.JiraPushResultResponse; +import com.kntro.reqsai.integrations.interfaces.rest.dto.response.ProjectJiraTargetResponse; + +/** Maps integration domain/results to REST responses. Never emits the API token. */ +public final class IntegrationResponseMapper { + + private IntegrationResponseMapper() { + throw new UnsupportedOperationException("Utility class - do not instantiate"); + } + + public static IntegrationConnectionResponse toResponse(IntegrationConnection c) { + return new IntegrationConnectionResponse( + c.getId(), + c.getOrganizationId(), + c.getProvider().name(), + c.getSiteUrl(), + c.getEmail(), + c.getStatus().name(), + c.getLastVerifiedAt(), + c.getCreatedAt(), + c.getUpdatedAt()); + } + + public static ConnectionTestResponse toResponse(ConnectionTestResult r) { + return new ConnectionTestResponse(r.ok(), r.accountName()); + } + + public static JiraProjectResponse toResponse(RemoteProject p) { + return new JiraProjectResponse(p.key(), p.name()); + } + + public static JiraIssueTypeResponse toResponse(RemoteIssueType t) { + return new JiraIssueTypeResponse(t.id(), t.name()); + } + + public static ProjectJiraTargetResponse toResponse(ProjectIntegrationTarget t) { + return new ProjectJiraTargetResponse( + t.getId(), + t.getProjectId(), + t.getConnectionId(), + t.getJiraProjectKey(), + t.getIssueTypeName(), + t.getCreatedAt(), + t.getUpdatedAt()); + } + + public static JiraPushResultResponse toResponse(StoryPushResult r) { + return new JiraPushResultResponse(r.storyId(), r.jiraIssueKey(), r.jiraIssueUrl(), r.error()); + } + + public static BatchPushResponse toResponse(BatchPushResult r) { + return new BatchPushResponse( + r.results().stream().map(IntegrationResponseMapper::toResponse).toList(), + r.pushed(), + r.failed()); + } +} diff --git a/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/swagger/OrganizationIntegrationController.java b/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/swagger/OrganizationIntegrationController.java new file mode 100644 index 00000000..033e843f --- /dev/null +++ b/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/swagger/OrganizationIntegrationController.java @@ -0,0 +1,123 @@ +package com.kntro.reqsai.integrations.interfaces.rest.swagger; + +import com.kntro.reqsai.integrations.interfaces.rest.dto.request.ConnectJiraRequest; +import com.kntro.reqsai.integrations.interfaces.rest.dto.response.ConnectionTestResponse; +import com.kntro.reqsai.integrations.interfaces.rest.dto.response.IntegrationConnectionResponse; +import com.kntro.reqsai.integrations.interfaces.rest.dto.response.JiraIssueTypeResponse; +import com.kntro.reqsai.integrations.interfaces.rest.dto.response.JiraProjectResponse; +import com.kntro.reqsai.shared.infrastructure.configuration.ApiVersioning; +import com.kntro.reqsai.shared.infrastructure.documentation.openapi.OpenApiConfiguration; +import com.kntro.reqsai.shared.infrastructure.documentation.openapi.annotations.ApiResponseBadRequest; +import com.kntro.reqsai.shared.infrastructure.documentation.openapi.annotations.ApiResponseConflict; +import com.kntro.reqsai.shared.infrastructure.documentation.openapi.annotations.ApiResponseNotFound; +import com.kntro.reqsai.shared.infrastructure.documentation.openapi.annotations.ApiStandardErrorResponses; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import java.util.List; +import java.util.UUID; + +@RequestMapping( + path = ApiVersioning.BASE + "/organizations/{orgId}/integrations", + produces = MediaType.APPLICATION_JSON_VALUE) +@Tag(name = "Organization Integrations", description = "Org-level third-party integration connections (Jira)") +public interface OrganizationIntegrationController { + + @Operation(summary = "List organization integration connections", + description = "Returns the organization's integration connections. The API token is never returned.") + @ApiResponse(responseCode = "200", description = "Connections listed", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)) + @ApiStandardErrorResponses + @SecurityRequirement(name = OpenApiConfiguration.BEARER_SCHEME) + @GetMapping(version = ApiVersioning.V1) + ResponseEntity> listConnections( + @Parameter(description = "Organization UUID") @PathVariable UUID orgId, + Authentication authentication); + + @Operation(summary = "Connect a Jira integration", + description = """ + Verifies the supplied Jira credentials against Jira Cloud and, on success, stores an + encrypted connection. Returns 409 when an active connection already exists, and + 401/502 when Jira rejects or is unreachable.""") + @ApiResponse(responseCode = "201", description = "Jira connection created", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = IntegrationConnectionResponse.class))) + @ApiResponseBadRequest + @ApiResponseConflict + @ApiStandardErrorResponses + @SecurityRequirement(name = OpenApiConfiguration.BEARER_SCHEME) + @PostMapping(value = "/jira", version = ApiVersioning.V1) + ResponseEntity connectJira( + @Parameter(description = "Organization UUID") @PathVariable UUID orgId, + @Valid @RequestBody ConnectJiraRequest request, + Authentication authentication); + + @Operation(summary = "Test an integration connection", + description = "Re-verifies the stored credential against the provider. Never fails the request.") + @ApiResponse(responseCode = "200", description = "Test result", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = ConnectionTestResponse.class))) + @ApiResponseNotFound + @ApiStandardErrorResponses + @SecurityRequirement(name = OpenApiConfiguration.BEARER_SCHEME) + @PostMapping(value = "/{connectionId}/test", version = ApiVersioning.V1) + ResponseEntity testConnection( + @Parameter(description = "Organization UUID") @PathVariable UUID orgId, + @Parameter(description = "Connection UUID") @PathVariable UUID connectionId, + Authentication authentication); + + @Operation(summary = "Delete an integration connection", + description = "Removes the connection; project targets referencing it are cascaded away.") + @ApiResponse(responseCode = "204", description = "Connection deleted") + @ApiResponseNotFound + @ApiStandardErrorResponses + @SecurityRequirement(name = OpenApiConfiguration.BEARER_SCHEME) + @DeleteMapping(value = "/{connectionId}", version = ApiVersioning.V1) + ResponseEntity deleteConnection( + @Parameter(description = "Organization UUID") @PathVariable UUID orgId, + @Parameter(description = "Connection UUID") @PathVariable UUID connectionId, + Authentication authentication); + + @Operation(summary = "List Jira projects for a connection", + description = "Lists the Jira projects visible to the connection's credentials.") + @ApiResponse(responseCode = "200", description = "Jira projects", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)) + @ApiResponseNotFound + @ApiStandardErrorResponses + @SecurityRequirement(name = OpenApiConfiguration.BEARER_SCHEME) + @GetMapping(value = "/{connectionId}/jira/projects", version = ApiVersioning.V1) + ResponseEntity> listJiraProjects( + @Parameter(description = "Organization UUID") @PathVariable UUID orgId, + @Parameter(description = "Connection UUID") @PathVariable UUID connectionId, + Authentication authentication); + + @Operation(summary = "List Jira issue types", + description = "Lists the Jira issue types available to the connection for the given project key.") + @ApiResponse(responseCode = "200", description = "Jira issue types", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)) + @ApiResponseNotFound + @ApiStandardErrorResponses + @SecurityRequirement(name = OpenApiConfiguration.BEARER_SCHEME) + @GetMapping(value = "/{connectionId}/jira/issue-types", version = ApiVersioning.V1) + ResponseEntity> listJiraIssueTypes( + @Parameter(description = "Organization UUID") @PathVariable UUID orgId, + @Parameter(description = "Connection UUID") @PathVariable UUID connectionId, + @Parameter(description = "Jira project key", example = "PAY") @RequestParam String projectKey, + Authentication authentication); +} diff --git a/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/swagger/ProjectIntegrationController.java b/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/swagger/ProjectIntegrationController.java new file mode 100644 index 00000000..9e76873d --- /dev/null +++ b/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/swagger/ProjectIntegrationController.java @@ -0,0 +1,108 @@ +package com.kntro.reqsai.integrations.interfaces.rest.swagger; + +import com.kntro.reqsai.integrations.interfaces.rest.dto.request.SaveProjectTargetRequest; +import com.kntro.reqsai.integrations.interfaces.rest.dto.response.BatchPushResponse; +import com.kntro.reqsai.integrations.interfaces.rest.dto.response.JiraPushResultResponse; +import com.kntro.reqsai.integrations.interfaces.rest.dto.response.ProjectJiraTargetResponse; +import com.kntro.reqsai.shared.infrastructure.configuration.ApiVersioning; +import com.kntro.reqsai.shared.infrastructure.documentation.openapi.OpenApiConfiguration; +import com.kntro.reqsai.shared.infrastructure.documentation.openapi.annotations.ApiResponseBadRequest; +import com.kntro.reqsai.shared.infrastructure.documentation.openapi.annotations.ApiResponseConflict; +import com.kntro.reqsai.shared.infrastructure.documentation.openapi.annotations.ApiResponseNotFound; +import com.kntro.reqsai.shared.infrastructure.documentation.openapi.annotations.ApiStandardErrorResponses; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; + +import java.util.UUID; + +@RequestMapping( + path = ApiVersioning.BASE + "/projects/{projectId}/integration/jira", + produces = MediaType.APPLICATION_JSON_VALUE) +@Tag(name = "Project Integration", description = "Project-level Jira push target and story export") +public interface ProjectIntegrationController { + + @Operation(summary = "Get the project's Jira target", + description = "Returns the project's configured Jira push target, 404 when none is set.") + @ApiResponse(responseCode = "200", description = "Jira target", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = ProjectJiraTargetResponse.class))) + @ApiResponseNotFound + @ApiStandardErrorResponses + @SecurityRequirement(name = OpenApiConfiguration.BEARER_SCHEME) + @GetMapping(value = "/target", version = ApiVersioning.V1) + ResponseEntity getTarget( + @Parameter(description = "Project UUID") @PathVariable UUID projectId, + Authentication authentication); + + @Operation(summary = "Set the project's Jira target", + description = "Creates or replaces the single Jira push target for the project (upsert).") + @ApiResponse(responseCode = "200", description = "Jira target saved", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = ProjectJiraTargetResponse.class))) + @ApiResponseBadRequest + @ApiResponseNotFound + @ApiStandardErrorResponses + @SecurityRequirement(name = OpenApiConfiguration.BEARER_SCHEME) + @PutMapping(value = "/target", version = ApiVersioning.V1) + ResponseEntity saveTarget( + @Parameter(description = "Project UUID") @PathVariable UUID projectId, + @Valid @RequestBody SaveProjectTargetRequest request, + Authentication authentication); + + @Operation(summary = "Delete the project's Jira target", + description = "Removes the project's Jira push target.") + @ApiResponse(responseCode = "204", description = "Jira target deleted") + @ApiResponseNotFound + @ApiStandardErrorResponses + @SecurityRequirement(name = OpenApiConfiguration.BEARER_SCHEME) + @DeleteMapping(value = "/target", version = ApiVersioning.V1) + ResponseEntity deleteTarget( + @Parameter(description = "Project UUID") @PathVariable UUID projectId, + Authentication authentication); + + @Operation(summary = "Push one story to Jira", + description = "Pushes a single story to the project's Jira target. 409 when no target is configured.") + @ApiResponse(responseCode = "200", description = "Story pushed", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = JiraPushResultResponse.class))) + @ApiResponseConflict + @ApiResponseNotFound + @ApiStandardErrorResponses + @SecurityRequirement(name = OpenApiConfiguration.BEARER_SCHEME) + @PostMapping(value = "/stories/{storyId}/push", version = ApiVersioning.V1) + ResponseEntity pushStory( + @Parameter(description = "Project UUID") @PathVariable UUID projectId, + @Parameter(description = "Story UUID") @PathVariable UUID storyId, + Authentication authentication); + + @Operation(summary = "Push all stories to Jira", + description = """ + Pushes every project story to the Jira target. Per-story failures are captured in the + results and do not abort the batch. 409 when no target is configured.""") + @ApiResponse(responseCode = "200", description = "Batch push result", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = BatchPushResponse.class))) + @ApiResponseConflict + @ApiStandardErrorResponses + @SecurityRequirement(name = OpenApiConfiguration.BEARER_SCHEME) + @PostMapping(value = "/stories/push-all", version = ApiVersioning.V1) + ResponseEntity pushAllStories( + @Parameter(description = "Project UUID") @PathVariable UUID projectId, + Authentication authentication); +} diff --git a/src/test/java/com/kntro/reqsai/architecture/ArchitectureTests.java b/src/test/java/com/kntro/reqsai/architecture/ArchitectureTests.java index 9f6c4859..8337c247 100644 --- a/src/test/java/com/kntro/reqsai/architecture/ArchitectureTests.java +++ b/src/test/java/com/kntro/reqsai/architecture/ArchitectureTests.java @@ -22,7 +22,8 @@ class ArchitectureTests { "..discovery.domain.exception..", "..workspace.domain.exception..", "..iam.domain.exception..", - "..billing.domain.exception..") + "..billing.domain.exception..", + "..integrations.domain.exception..") .should().dependOnClassesThat().resideInAPackage("org.springframework..") .because("domain layer must be framework-agnostic; " + "shared.domain.model uses Spring Data auditing intentionally, " @@ -39,7 +40,8 @@ class ArchitectureTests { "..workspace.domain.valueobjects..", "..iam.domain.model..", "..billing.domain.model..", - "..billing.domain.model.valueobjects..") + "..billing.domain.model.valueobjects..", + "..integrations.domain.model..") .should().dependOnClassesThat().resideInAPackage("jakarta.persistence..") .because("domain must not depend on JPA — use ports; " + "Active Record pattern exempts model and value-object packages"); From 15d88e0bc0ec35ed2f419e6eca3b1718245d5ddd Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Mon, 6 Jul 2026 19:31:56 -0500 Subject: [PATCH 22/72] test(integrations): cover encryption, adf builder, handlers and jira push integration flow --- .../integrations/StubJiraProviderConfig.java | 54 +++++ .../ConnectJiraCommandHandlerTest.java | 96 +++++++++ .../PushAllStoriesCommandHandlerTest.java | 93 +++++++++ .../handler/PushStoryCommandHandlerTest.java | 93 +++++++++ .../TestConnectionQueryHandlerTest.java | 86 ++++++++ .../crypto/AesGcmCipherTest.java | 60 ++++++ .../jira/JiraAdfBuilderTest.java | 57 +++++ .../JiraIntegrationPushIntegrationTest.java | 194 ++++++++++++++++++ 8 files changed, 733 insertions(+) create mode 100644 src/test/java/com/kntro/reqsai/integrations/StubJiraProviderConfig.java create mode 100644 src/test/java/com/kntro/reqsai/integrations/application/handler/ConnectJiraCommandHandlerTest.java create mode 100644 src/test/java/com/kntro/reqsai/integrations/application/handler/PushAllStoriesCommandHandlerTest.java create mode 100644 src/test/java/com/kntro/reqsai/integrations/application/handler/PushStoryCommandHandlerTest.java create mode 100644 src/test/java/com/kntro/reqsai/integrations/application/handler/TestConnectionQueryHandlerTest.java create mode 100644 src/test/java/com/kntro/reqsai/integrations/infrastructure/crypto/AesGcmCipherTest.java create mode 100644 src/test/java/com/kntro/reqsai/integrations/infrastructure/jira/JiraAdfBuilderTest.java create mode 100644 src/test/java/com/kntro/reqsai/integrations/interfaces/rest/JiraIntegrationPushIntegrationTest.java diff --git a/src/test/java/com/kntro/reqsai/integrations/StubJiraProviderConfig.java b/src/test/java/com/kntro/reqsai/integrations/StubJiraProviderConfig.java new file mode 100644 index 00000000..94ee2755 --- /dev/null +++ b/src/test/java/com/kntro/reqsai/integrations/StubJiraProviderConfig.java @@ -0,0 +1,54 @@ +package com.kntro.reqsai.integrations; + +import com.kntro.reqsai.discovery.api.StoryView; +import com.kntro.reqsai.integrations.application.port.IntegrationProvider; +import com.kntro.reqsai.integrations.domain.model.IntegrationProviderType; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; + +import java.util.List; + +/** + * Deterministic stand-in for {@code JiraProvider} used by integration tests: it stubs the RestClient + * boundary so the tests exercise the full connect → target → push flow against a real tenant schema + * WITHOUT hitting Jira. Verification always succeeds; a push returns a synthetic issue key/url derived + * from the story id so assertions are stable. + */ +@TestConfiguration +public class StubJiraProviderConfig { + + public static final String ACCOUNT_NAME = "Stub Jira Admin"; + + @Bean + @Primary + public IntegrationProvider stubJiraProvider() { + return new IntegrationProvider() { + @Override + public IntegrationProviderType type() { + return IntegrationProviderType.JIRA; + } + + @Override + public String verify(ProviderCredentials credentials) { + return ACCOUNT_NAME; + } + + @Override + public List listProjects(ProviderCredentials credentials) { + return List.of(new RemoteProject("PAY", "Payments")); + } + + @Override + public List listIssueTypes(ProviderCredentials credentials, String projectKey) { + return List.of(new RemoteIssueType("10001", "Story")); + } + + @Override + public PushedIssue pushStory(ProviderCredentials c, String projectKey, String issueTypeName, StoryView story) { + String key = projectKey + "-" + Math.abs(story.storyId().hashCode() % 1000); + return new PushedIssue(key, c.siteUrl() + "/browse/" + key); + } + }; + } +} diff --git a/src/test/java/com/kntro/reqsai/integrations/application/handler/ConnectJiraCommandHandlerTest.java b/src/test/java/com/kntro/reqsai/integrations/application/handler/ConnectJiraCommandHandlerTest.java new file mode 100644 index 00000000..47c56093 --- /dev/null +++ b/src/test/java/com/kntro/reqsai/integrations/application/handler/ConnectJiraCommandHandlerTest.java @@ -0,0 +1,96 @@ +package com.kntro.reqsai.integrations.application.handler; + +import com.kntro.reqsai.integrations.application.command.ConnectJiraCommand; +import com.kntro.reqsai.integrations.application.port.IntegrationConnectionRepository; +import com.kntro.reqsai.integrations.application.port.IntegrationProvider; +import com.kntro.reqsai.integrations.application.service.ProviderRegistry; +import com.kntro.reqsai.integrations.domain.model.ConnectionStatus; +import com.kntro.reqsai.integrations.domain.model.IntegrationConnection; +import com.kntro.reqsai.integrations.domain.model.IntegrationProviderType; +import com.kntro.reqsai.integrations.infrastructure.exception.IntegrationsInfrastructureExceptions; +import com.kntro.reqsai.shared.domain.exception.DomainException; +import com.kntro.reqsai.shared.domain.exception.InfrastructureException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@DisplayName("Application: Connect Jira") +@ExtendWith(MockitoExtension.class) +class ConnectJiraCommandHandlerTest { + + @Mock + private IntegrationConnectionRepository connections; + @Mock + private IntegrationProvider jiraProvider; + + private ConnectJiraCommandHandler handler; + + @BeforeEach + void setUp() { + when(jiraProvider.type()).thenReturn(IntegrationProviderType.JIRA); + handler = new ConnectJiraCommandHandler(connections, new ProviderRegistry(List.of(jiraProvider))); + } + + @Test + @DisplayName("verifies the credential then persists an encrypted connection") + void connects_after_verifying() { + UUID orgId = UUID.randomUUID(); + when(connections.existsByOrganizationIdAndProviderAndStatusNot( + eq(orgId), eq(IntegrationProviderType.JIRA), eq(ConnectionStatus.DISCONNECTED))).thenReturn(false); + when(jiraProvider.verify(any())).thenReturn("Jane Admin"); + when(connections.save(any(IntegrationConnection.class))).thenAnswer(i -> i.getArgument(0)); + + IntegrationConnection saved = handler.handle(new ConnectJiraCommand( + orgId, "https://acme.atlassian.net/", "pm@acme.com", "tok", UUID.randomUUID())); + + verify(jiraProvider).verify(any()); + verify(connections).save(any(IntegrationConnection.class)); + assertThat(saved.getProvider()).isEqualTo(IntegrationProviderType.JIRA); + assertThat(saved.getSiteUrl()).isEqualTo("https://acme.atlassian.net"); // trailing slash trimmed + assertThat(saved.getStatus()).isEqualTo(ConnectionStatus.CONNECTED); + } + + @Test + @DisplayName("rejects a second active connection with a 409 domain error") + void rejects_duplicate() { + UUID orgId = UUID.randomUUID(); + when(connections.existsByOrganizationIdAndProviderAndStatusNot( + eq(orgId), eq(IntegrationProviderType.JIRA), eq(ConnectionStatus.DISCONNECTED))).thenReturn(true); + + assertThatThrownBy(() -> handler.handle(new ConnectJiraCommand( + orgId, "https://acme.atlassian.net", "pm@acme.com", "tok", UUID.randomUUID()))) + .isInstanceOf(DomainException.class); + + verify(jiraProvider, never()).verify(any()); + verify(connections, never()).save(any()); + } + + @Test + @DisplayName("propagates a verification failure and persists nothing") + void propagates_verify_failure() { + UUID orgId = UUID.randomUUID(); + when(connections.existsByOrganizationIdAndProviderAndStatusNot( + eq(orgId), eq(IntegrationProviderType.JIRA), eq(ConnectionStatus.DISCONNECTED))).thenReturn(false); + when(jiraProvider.verify(any())).thenThrow(IntegrationsInfrastructureExceptions.jiraAuthFailed()); + + assertThatThrownBy(() -> handler.handle(new ConnectJiraCommand( + orgId, "https://acme.atlassian.net", "pm@acme.com", "bad", UUID.randomUUID()))) + .isInstanceOf(InfrastructureException.class); + + verify(connections, never()).save(any()); + } +} diff --git a/src/test/java/com/kntro/reqsai/integrations/application/handler/PushAllStoriesCommandHandlerTest.java b/src/test/java/com/kntro/reqsai/integrations/application/handler/PushAllStoriesCommandHandlerTest.java new file mode 100644 index 00000000..61e77ed0 --- /dev/null +++ b/src/test/java/com/kntro/reqsai/integrations/application/handler/PushAllStoriesCommandHandlerTest.java @@ -0,0 +1,93 @@ +package com.kntro.reqsai.integrations.application.handler; + +import com.kntro.reqsai.discovery.api.DiscoveryStoryReadPort; +import com.kntro.reqsai.discovery.api.StoryView; +import com.kntro.reqsai.integrations.application.command.PushAllStoriesCommand; +import com.kntro.reqsai.integrations.application.port.IntegrationConnectionRepository; +import com.kntro.reqsai.integrations.application.port.IntegrationProvider; +import com.kntro.reqsai.integrations.application.port.IntegrationProvider.PushedIssue; +import com.kntro.reqsai.integrations.application.port.ProjectIntegrationTargetRepository; +import com.kntro.reqsai.integrations.application.result.BatchPushResult; +import com.kntro.reqsai.integrations.application.service.ProviderCredentialsFactory; +import com.kntro.reqsai.integrations.application.service.ProviderRegistry; +import com.kntro.reqsai.integrations.application.service.StoryPushService; +import com.kntro.reqsai.integrations.domain.model.ConnectionStatus; +import com.kntro.reqsai.integrations.domain.model.IntegrationConnection; +import com.kntro.reqsai.integrations.domain.model.IntegrationProviderType; +import com.kntro.reqsai.integrations.domain.model.ProjectIntegrationTarget; +import com.kntro.reqsai.integrations.infrastructure.exception.IntegrationsInfrastructureExceptions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +@DisplayName("Application: Push all stories (partial failure)") +@ExtendWith(MockitoExtension.class) +class PushAllStoriesCommandHandlerTest { + + @Mock private ProjectIntegrationTargetRepository targets; + @Mock private DiscoveryStoryReadPort stories; + @Mock private IntegrationConnectionRepository connections; + @Mock private IntegrationProvider jiraProvider; + @Mock private ProviderCredentialsFactory credentialsFactory; + + private PushAllStoriesCommandHandler handler; + + @BeforeEach + void setUp() { + when(jiraProvider.type()).thenReturn(IntegrationProviderType.JIRA); + StoryPushService pushService = new StoryPushService( + connections, new ProviderRegistry(List.of(jiraProvider)), credentialsFactory); + handler = new PushAllStoriesCommandHandler(targets, stories, pushService); + } + + @Test + @DisplayName("captures a per-story failure without aborting the batch") + void captures_partial_failure() { + UUID projectId = UUID.randomUUID(); + UUID connectionId = UUID.randomUUID(); + ProjectIntegrationTarget target = new ProjectIntegrationTarget(projectId, connectionId, "PAY", "Story"); + IntegrationConnection connection = new IntegrationConnection( + UUID.randomUUID(), IntegrationProviderType.JIRA, "https://acme.atlassian.net", + "pm@acme.com", "tok", Instant.now()); + + when(targets.findByProjectId(projectId)).thenReturn(Optional.of(target)); + when(connections.findById(connectionId)).thenReturn(Optional.of(connection)); + when(credentialsFactory.from(connection)).thenReturn( + new IntegrationProvider.ProviderCredentials("https://acme.atlassian.net", "pm@acme.com", "tok")); + + StoryView ok = story(projectId, "Good story"); + StoryView bad = story(projectId, "Bad story"); + when(stories.listStories(projectId)).thenReturn(List.of(ok, bad)); + + when(jiraProvider.pushStory(any(), eq("PAY"), eq("Story"), eq(ok))) + .thenReturn(new PushedIssue("PAY-1", "https://acme.atlassian.net/browse/PAY-1")); + when(jiraProvider.pushStory(any(), eq("PAY"), eq("Story"), eq(bad))) + .thenThrow(IntegrationsInfrastructureExceptions.jiraPushFailed("400")); + + BatchPushResult result = handler.handle(new PushAllStoriesCommand(projectId, UUID.randomUUID())); + + assertThat(result.pushed()).isEqualTo(1); + assertThat(result.failed()).isEqualTo(1); + assertThat(result.results()).hasSize(2); + assertThat(result.results().get(0).jiraIssueKey()).isEqualTo("PAY-1"); + assertThat(result.results().get(1).error()).isEqualTo("JIRA_PUSH_FAILED"); + assertThat(result.results().get(1).jiraIssueKey()).isNull(); + } + + private static StoryView story(UUID projectId, String title) { + return new StoryView(UUID.randomUUID(), projectId, title, "user", "do", "benefit", "MEDIUM", null, List.of()); + } +} diff --git a/src/test/java/com/kntro/reqsai/integrations/application/handler/PushStoryCommandHandlerTest.java b/src/test/java/com/kntro/reqsai/integrations/application/handler/PushStoryCommandHandlerTest.java new file mode 100644 index 00000000..9b3196a7 --- /dev/null +++ b/src/test/java/com/kntro/reqsai/integrations/application/handler/PushStoryCommandHandlerTest.java @@ -0,0 +1,93 @@ +package com.kntro.reqsai.integrations.application.handler; + +import com.kntro.reqsai.discovery.api.DiscoveryStoryReadPort; +import com.kntro.reqsai.discovery.api.StoryView; +import com.kntro.reqsai.integrations.application.command.PushStoryCommand; +import com.kntro.reqsai.integrations.application.port.IntegrationConnectionRepository; +import com.kntro.reqsai.integrations.application.port.IntegrationProvider; +import com.kntro.reqsai.integrations.application.port.IntegrationProvider.PushedIssue; +import com.kntro.reqsai.integrations.application.port.ProjectIntegrationTargetRepository; +import com.kntro.reqsai.integrations.application.result.StoryPushResult; +import com.kntro.reqsai.integrations.application.service.ProviderCredentialsFactory; +import com.kntro.reqsai.integrations.application.service.ProviderRegistry; +import com.kntro.reqsai.integrations.application.service.StoryPushService; +import com.kntro.reqsai.integrations.domain.model.IntegrationConnection; +import com.kntro.reqsai.integrations.domain.model.IntegrationProviderType; +import com.kntro.reqsai.integrations.domain.model.ProjectIntegrationTarget; +import com.kntro.reqsai.shared.domain.exception.DomainException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@DisplayName("Application: Push single story") +@ExtendWith(MockitoExtension.class) +class PushStoryCommandHandlerTest { + + @Mock private ProjectIntegrationTargetRepository targets; + @Mock private DiscoveryStoryReadPort stories; + @Mock private IntegrationConnectionRepository connections; + @Mock private IntegrationProvider jiraProvider; + @Mock private ProviderCredentialsFactory credentialsFactory; + + private PushStoryCommandHandler handler; + + @BeforeEach + void setUp() { + when(jiraProvider.type()).thenReturn(IntegrationProviderType.JIRA); + StoryPushService pushService = new StoryPushService( + connections, new ProviderRegistry(List.of(jiraProvider)), credentialsFactory); + handler = new PushStoryCommandHandler(targets, stories, pushService); + } + + @Test + @DisplayName("pushes the story and returns the issue key + url") + void pushes_story() { + UUID projectId = UUID.randomUUID(); + UUID storyId = UUID.randomUUID(); + UUID connectionId = UUID.randomUUID(); + ProjectIntegrationTarget target = new ProjectIntegrationTarget(projectId, connectionId, "PAY", "Story"); + IntegrationConnection connection = new IntegrationConnection( + UUID.randomUUID(), IntegrationProviderType.JIRA, "https://acme.atlassian.net", + "pm@acme.com", "tok", Instant.now()); + StoryView story = new StoryView(storyId, projectId, "T", "user", "do", "benefit", "HIGH", 2, List.of()); + + when(targets.findByProjectId(projectId)).thenReturn(Optional.of(target)); + when(stories.findStory(projectId, storyId)).thenReturn(Optional.of(story)); + when(connections.findById(connectionId)).thenReturn(Optional.of(connection)); + when(credentialsFactory.from(connection)).thenReturn( + new IntegrationProvider.ProviderCredentials("https://acme.atlassian.net", "pm@acme.com", "tok")); + when(jiraProvider.pushStory(any(), any(), any(), any())) + .thenReturn(new PushedIssue("PAY-7", "https://acme.atlassian.net/browse/PAY-7")); + + StoryPushResult result = handler.handle(new PushStoryCommand(projectId, storyId, UUID.randomUUID())); + + assertThat(result.jiraIssueKey()).isEqualTo("PAY-7"); + assertThat(result.jiraIssueUrl()).isEqualTo("https://acme.atlassian.net/browse/PAY-7"); + assertThat(result.isSuccess()).isTrue(); + } + + @Test + @DisplayName("returns 409 when no target is configured") + void no_target_configured() { + UUID projectId = UUID.randomUUID(); + when(targets.findByProjectId(projectId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> handler.handle(new PushStoryCommand(projectId, UUID.randomUUID(), UUID.randomUUID()))) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).error().code()) + .isEqualTo("INTEGRATION_TARGET_NOT_CONFIGURED"); + } +} diff --git a/src/test/java/com/kntro/reqsai/integrations/application/handler/TestConnectionQueryHandlerTest.java b/src/test/java/com/kntro/reqsai/integrations/application/handler/TestConnectionQueryHandlerTest.java new file mode 100644 index 00000000..bfe88252 --- /dev/null +++ b/src/test/java/com/kntro/reqsai/integrations/application/handler/TestConnectionQueryHandlerTest.java @@ -0,0 +1,86 @@ +package com.kntro.reqsai.integrations.application.handler; + +import com.kntro.reqsai.integrations.application.port.IntegrationConnectionRepository; +import com.kntro.reqsai.integrations.application.port.IntegrationProvider; +import com.kntro.reqsai.integrations.application.query.TestConnectionQuery; +import com.kntro.reqsai.integrations.application.result.ConnectionTestResult; +import com.kntro.reqsai.integrations.application.service.ProviderCredentialsFactory; +import com.kntro.reqsai.integrations.application.service.ProviderRegistry; +import com.kntro.reqsai.integrations.domain.model.ConnectionStatus; +import com.kntro.reqsai.integrations.domain.model.IntegrationConnection; +import com.kntro.reqsai.integrations.domain.model.IntegrationProviderType; +import com.kntro.reqsai.integrations.infrastructure.exception.IntegrationsInfrastructureExceptions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@DisplayName("Application: Test connection") +@ExtendWith(MockitoExtension.class) +class TestConnectionQueryHandlerTest { + + @Mock private IntegrationConnectionRepository connections; + @Mock private IntegrationProvider jiraProvider; + @Mock private ProviderCredentialsFactory credentialsFactory; + + private TestConnectionQueryHandler handler; + + @BeforeEach + void setUp() { + when(jiraProvider.type()).thenReturn(IntegrationProviderType.JIRA); + handler = new TestConnectionQueryHandler( + connections, new ProviderRegistry(List.of(jiraProvider)), credentialsFactory); + } + + @Test + @DisplayName("returns ok + account name and marks the connection verified on success") + void ok_on_success() { + UUID orgId = UUID.randomUUID(); + UUID connectionId = UUID.randomUUID(); + IntegrationConnection connection = connection(orgId); + when(connections.findByIdAndOrganizationId(connectionId, orgId)).thenReturn(Optional.of(connection)); + when(credentialsFactory.from(connection)).thenReturn( + new IntegrationProvider.ProviderCredentials("https://acme.atlassian.net", "pm@acme.com", "tok")); + when(jiraProvider.verify(any())).thenReturn("Jane Admin"); + + ConnectionTestResult result = handler.handle(new TestConnectionQuery(orgId, connectionId, UUID.randomUUID())); + + assertThat(result.ok()).isTrue(); + assertThat(result.accountName()).isEqualTo("Jane Admin"); + assertThat(connection.getStatus()).isEqualTo(ConnectionStatus.CONNECTED); + } + + @Test + @DisplayName("returns ok=false and marks DEGRADED on verification failure (never fails the request)") + void degraded_on_failure() { + UUID orgId = UUID.randomUUID(); + UUID connectionId = UUID.randomUUID(); + IntegrationConnection connection = connection(orgId); + when(connections.findByIdAndOrganizationId(connectionId, orgId)).thenReturn(Optional.of(connection)); + when(credentialsFactory.from(connection)).thenReturn( + new IntegrationProvider.ProviderCredentials("https://acme.atlassian.net", "pm@acme.com", "tok")); + when(jiraProvider.verify(any())).thenThrow(IntegrationsInfrastructureExceptions.jiraAuthFailed()); + + ConnectionTestResult result = handler.handle(new TestConnectionQuery(orgId, connectionId, UUID.randomUUID())); + + assertThat(result.ok()).isFalse(); + assertThat(result.accountName()).isNull(); + assertThat(connection.getStatus()).isEqualTo(ConnectionStatus.DEGRADED); + } + + private static IntegrationConnection connection(UUID orgId) { + return new IntegrationConnection(orgId, IntegrationProviderType.JIRA, + "https://acme.atlassian.net", "pm@acme.com", "tok", Instant.now()); + } +} diff --git a/src/test/java/com/kntro/reqsai/integrations/infrastructure/crypto/AesGcmCipherTest.java b/src/test/java/com/kntro/reqsai/integrations/infrastructure/crypto/AesGcmCipherTest.java new file mode 100644 index 00000000..c1831b49 --- /dev/null +++ b/src/test/java/com/kntro/reqsai/integrations/infrastructure/crypto/AesGcmCipherTest.java @@ -0,0 +1,60 @@ +package com.kntro.reqsai.integrations.infrastructure.crypto; + +import com.kntro.reqsai.shared.domain.exception.InfrastructureException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DisplayName("Infrastructure: AES-256-GCM cipher") +class AesGcmCipherTest { + + private static final String KEY = Base64.getEncoder().encodeToString(new byte[32]); + + @Test + @DisplayName("encrypt then decrypt round-trips the plaintext") + void round_trips() { + AesGcmCipher cipher = new AesGcmCipher(KEY); + byte[] plaintext = "super-secret-jira-token".getBytes(StandardCharsets.UTF_8); + + byte[] encrypted = cipher.encrypt(plaintext); + byte[] decrypted = cipher.decrypt(encrypted); + + assertThat(new String(decrypted, StandardCharsets.UTF_8)).isEqualTo("super-secret-jira-token"); + assertThat(encrypted).isNotEqualTo(plaintext); + // IV(12) is prepended, so ciphertext is longer than plaintext. + assertThat(encrypted.length).isGreaterThan(plaintext.length + 12); + } + + @Test + @DisplayName("uses a fresh IV per value (same input yields different ciphertext)") + void fresh_iv_per_value() { + AesGcmCipher cipher = new AesGcmCipher(KEY); + byte[] plaintext = "token".getBytes(StandardCharsets.UTF_8); + + assertThat(cipher.encrypt(plaintext)).isNotEqualTo(cipher.encrypt(plaintext)); + } + + @Test + @DisplayName("rejects a key that does not decode to 32 bytes") + void rejects_wrong_key_length() { + String shortKey = Base64.getEncoder().encodeToString(new byte[16]); + assertThatThrownBy(() -> new AesGcmCipher(shortKey)) + .isInstanceOf(InfrastructureException.class); + } + + @Test + @DisplayName("fails to decrypt tampered ciphertext (GCM auth tag)") + void detects_tampering() { + AesGcmCipher cipher = new AesGcmCipher(KEY); + byte[] encrypted = cipher.encrypt("token".getBytes(StandardCharsets.UTF_8)); + encrypted[encrypted.length - 1] ^= 0x01; // flip a bit in the tag + + assertThatThrownBy(() -> cipher.decrypt(encrypted)) + .isInstanceOf(InfrastructureException.class); + } +} diff --git a/src/test/java/com/kntro/reqsai/integrations/infrastructure/jira/JiraAdfBuilderTest.java b/src/test/java/com/kntro/reqsai/integrations/infrastructure/jira/JiraAdfBuilderTest.java new file mode 100644 index 00000000..590d6859 --- /dev/null +++ b/src/test/java/com/kntro/reqsai/integrations/infrastructure/jira/JiraAdfBuilderTest.java @@ -0,0 +1,57 @@ +package com.kntro.reqsai.integrations.infrastructure.jira; + +import com.kntro.reqsai.discovery.api.AcceptanceCriterionView; +import com.kntro.reqsai.discovery.api.StoryView; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("Infrastructure: Jira ADF builder") +class JiraAdfBuilderTest { + + @Test + @DisplayName("builds a valid ADF doc with the story statement, metadata and criteria bullets") + void builds_adf() { + StoryView story = new StoryView( + UUID.randomUUID(), UUID.randomUUID(), + "Login with Google", "user", "sign in with my Google account", "I don't manage another password", + "HIGH", 3, + List.of(new AcceptanceCriterionView("Happy path", "I have a Google account", "I click sign in", "I am logged in"))); + + Map doc = JiraAdfBuilder.buildDescription(story); + + assertThat(doc).containsEntry("type", "doc").containsEntry("version", 1); + @SuppressWarnings("unchecked") + List content = (List) doc.get("content"); + // paragraph (story) + paragraph (meta) + heading + bulletList + assertThat(content).hasSize(4); + String json = doc.toString(); + assertThat(json).contains("As user, I want to sign in with my Google account, so that I don't manage another password."); + assertThat(json).contains("Priority: HIGH"); + assertThat(json).contains("Story points: 3"); + assertThat(json).contains("Acceptance Criteria"); + assertThat(json).contains("Given I have a Google account, When I click sign in, Then I am logged in."); + } + + @Test + @DisplayName("omits the acceptance-criteria section when there are none") + void omits_criteria_when_empty() { + StoryView story = new StoryView( + UUID.randomUUID(), UUID.randomUUID(), + "Title", "user", "do", "benefit", "LOW", null, List.of()); + + Map doc = JiraAdfBuilder.buildDescription(story); + + @SuppressWarnings("unchecked") + List content = (List) doc.get("content"); + // just the two paragraphs, no heading/list + assertThat(content).hasSize(2); + assertThat(doc.toString()).doesNotContain("Acceptance Criteria"); + assertThat(doc.toString()).doesNotContain("Story points"); + } +} diff --git a/src/test/java/com/kntro/reqsai/integrations/interfaces/rest/JiraIntegrationPushIntegrationTest.java b/src/test/java/com/kntro/reqsai/integrations/interfaces/rest/JiraIntegrationPushIntegrationTest.java new file mode 100644 index 00000000..8dba42d8 --- /dev/null +++ b/src/test/java/com/kntro/reqsai/integrations/interfaces/rest/JiraIntegrationPushIntegrationTest.java @@ -0,0 +1,194 @@ +package com.kntro.reqsai.integrations.interfaces.rest; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.kntro.reqsai.integrations.StubJiraProviderConfig; +import com.kntro.reqsai.testsupport.AbstractIntegrationTest; +import com.kntro.reqsai.testsupport.StubEmbeddingConfig; +import com.kntro.reqsai.testsupport.TestJwtFactory; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; + +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * End-to-end test of the Jira integration slice across the full multitenant flow: creates an org + * (provisioning its tenant schema with the V21 integration tables), connects Jira at the org level + * (persisting an ENCRYPTED token), sets a project target, seeds a story, and pushes it — asserting the + * connection/target rows persist (token encrypted, never echoed) and the push maps to a Jira issue. + *

+ * The Jira RestClient boundary is stubbed via {@link StubJiraProviderConfig} so nothing hits real Jira. + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +@Import({StubJiraProviderConfig.class, StubEmbeddingConfig.class}) +@Tag("integration") +@DisplayName("Integration: Jira connection, target and push") +class JiraIntegrationPushIntegrationTest extends AbstractIntegrationTest { + + private static final String USER_ID = "00000000-0000-0000-0000-000000000001"; + private static final ObjectMapper JSON = new ObjectMapper(); + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Test + @DisplayName("connects Jira, sets a target and pushes a story end-to-end") + void connects_targets_and_pushes() throws Exception { + String suffix = UUID.randomUUID().toString().substring(0, 8); + String slug = "acme-" + suffix; + String schema = "tenant_" + slug; + String orgId = createOrg(suffix, slug); + UUID projectId = createProject(orgId, schema, "Payment Platform"); + + // Connect Jira at the org level (verify is stubbed to succeed) -> 201, token NOT echoed + ResponseEntity connectRes = client().post() + .uri("/api/organizations/{orgId}/integrations/jira", orgId) + .header("Authorization", TestJwtFactory.bearer(USER_ID, orgId, "ROLE_USER")) + .header("Api-Version", "1") + .contentType(MediaType.APPLICATION_JSON) + .body(Map.of("siteUrl", "https://acme.atlassian.net", + "email", "pm@acme.com", "apiToken", "super-secret-token")) + .exchange((req, res) -> ResponseEntity.status(res.getStatusCode()).body(res.bodyTo(String.class))); + + assertThat(connectRes.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(connectRes.getBody()).contains("\"provider\":\"JIRA\""); + assertThat(connectRes.getBody()).doesNotContain("super-secret-token"); + String connectionId = JSON.readTree(connectRes.getBody()).get("id").asText(); + + // The stored secret is ciphertext (BYTEA), not the plaintext token. + String storedHex = jdbcTemplate.queryForObject( + "SELECT encode(secret_ciphertext, 'escape') FROM \"" + schema + "\".integration_connections WHERE id = ?::uuid", + String.class, connectionId); + assertThat(storedHex).doesNotContain("super-secret-token"); + + // Seed a story in the tenant. + ResponseEntity storyRes = client().post().uri("/api/projects/{p}/stories", projectId) + .header("Authorization", TestJwtFactory.bearer(USER_ID, orgId, "ROLE_USER")) + .header("Api-Version", "1") + .contentType(MediaType.APPLICATION_JSON) + .body(Map.of("title", "Bulk import", "role", "analyst", + "action", "upload a CSV", "benefit", "save time", "priority", "HIGH")) + .exchange((req, res) -> ResponseEntity.status(res.getStatusCode()).body(res.bodyTo(String.class))); + assertThat(storyRes.getStatusCode()).isEqualTo(HttpStatus.CREATED); + String storyId = JSON.readTree(storyRes.getBody()).get("id").asText(); + + // Set the project target. + ResponseEntity targetRes = client().put() + .uri("/api/projects/{p}/integration/jira/target", projectId) + .header("Authorization", TestJwtFactory.bearer(USER_ID, orgId, "ROLE_USER")) + .header("Api-Version", "1") + .contentType(MediaType.APPLICATION_JSON) + .body(Map.of("connectionId", connectionId, "jiraProjectKey", "PAY", "issueTypeName", "Story")) + .exchange((req, res) -> ResponseEntity.status(res.getStatusCode()).body(res.bodyTo(String.class))); + assertThat(targetRes.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(targetRes.getBody()).contains("\"jiraProjectKey\":\"PAY\""); + + Integer targetCount = jdbcTemplate.queryForObject( + "SELECT count(*) FROM \"" + schema + "\".project_integration_targets WHERE project_id = ?::uuid", + Integer.class, projectId.toString()); + assertThat(targetCount).isEqualTo(1); + + // Push the story -> mapped to a Jira issue key/url by the stub provider. + ResponseEntity pushRes = client().post() + .uri("/api/projects/{p}/integration/jira/stories/{s}/push", projectId, storyId) + .header("Authorization", TestJwtFactory.bearer(USER_ID, orgId, "ROLE_USER")) + .header("Api-Version", "1") + .exchange((req, res) -> ResponseEntity.status(res.getStatusCode()).body(res.bodyTo(String.class))); + + assertThat(pushRes.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode push = JSON.readTree(pushRes.getBody()); + assertThat(push.get("storyId").asText()).isEqualTo(storyId); + assertThat(push.get("jiraIssueKey").asText()).startsWith("PAY-"); + assertThat(push.get("jiraIssueUrl").asText()).startsWith("https://acme.atlassian.net/browse/PAY-"); + assertThat(push.hasNonNull("error")).isFalse(); + + // push-all also succeeds for the single seeded story. + ResponseEntity pushAllRes = client().post() + .uri("/api/projects/{p}/integration/jira/stories/push-all", projectId) + .header("Authorization", TestJwtFactory.bearer(USER_ID, orgId, "ROLE_USER")) + .header("Api-Version", "1") + .exchange((req, res) -> ResponseEntity.status(res.getStatusCode()).body(res.bodyTo(String.class))); + assertThat(pushAllRes.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode all = JSON.readTree(pushAllRes.getBody()); + assertThat(all.get("pushed").asInt()).isEqualTo(1); + assertThat(all.get("failed").asInt()).isZero(); + } + + @Test + @DisplayName("rejects a second active Jira connection with 409") + void rejects_duplicate_connection() { + String suffix = UUID.randomUUID().toString().substring(0, 8); + String orgId = createOrg(suffix, "acme-" + suffix); + + connectJira(orgId); + ResponseEntity second = connectJira(orgId); + + assertThat(second.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + assertThat(second.getBody()).contains("INTEGRATION_ALREADY_CONNECTED"); + } + + @Test + @DisplayName("returns 409 when pushing with no target configured") + void push_without_target() { + String suffix = UUID.randomUUID().toString().substring(0, 8); + String orgId = createOrg(suffix, "acme-" + suffix); + UUID projectId = UUID.randomUUID(); + + ResponseEntity res = client().post() + .uri("/api/projects/{p}/integration/jira/stories/{s}/push", projectId, UUID.randomUUID()) + .header("Authorization", TestJwtFactory.bearer(USER_ID, orgId, "ROLE_USER")) + .header("Api-Version", "1") + .exchange((req, response) -> ResponseEntity.status(response.getStatusCode()) + .body(response.bodyTo(String.class))); + + assertThat(res.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + assertThat(res.getBody()).contains("INTEGRATION_TARGET_NOT_CONFIGURED"); + } + + private ResponseEntity connectJira(String orgId) { + return client().post().uri("/api/organizations/{orgId}/integrations/jira", orgId) + .header("Authorization", TestJwtFactory.bearer(USER_ID, orgId, "ROLE_USER")) + .header("Api-Version", "1") + .contentType(MediaType.APPLICATION_JSON) + .body(Map.of("siteUrl", "https://acme.atlassian.net", "email", "pm@acme.com", "apiToken", "tok")) + .exchange((req, res) -> ResponseEntity.status(res.getStatusCode()).body(res.bodyTo(String.class))); + } + + private UUID createProject(String orgId, String schema, String name) { + client().post().uri("/api/organizations/{orgId}/projects", orgId) + .header("Authorization", TestJwtFactory.bearer(USER_ID, orgId, "ROLE_USER")) + .header("Api-Version", "1") + .contentType(MediaType.APPLICATION_JSON) + .body(Map.of("name", name, "programmingLanguages", java.util.List.of("Java"), + "frameworks", java.util.List.of("Spring Boot"), "clientPlatforms", java.util.List.of("Web"), + "databases", java.util.List.of("PostgreSQL"), "architecture", "Hexagonal", "domain", "Fintech")) + .exchange((req, res) -> ResponseEntity.status(res.getStatusCode()).body(res.bodyTo(String.class))); + return UUID.fromString(jdbcTemplate.queryForObject( + "SELECT id::text FROM \"" + schema + "\".projects WHERE name = ?", String.class, name)); + } + + private String createOrg(String suffix, String expectedSlug) { + ResponseEntity orgRes = client().post().uri("/api/organizations") + .header("Authorization", TestJwtFactory.bearer(USER_ID, UUID.randomUUID().toString(), "ROLE_USER")) + .header("Api-Version", "1") + .contentType(MediaType.APPLICATION_JSON) + .body(Map.of("name", "Acme " + suffix)) + .exchange((req, res) -> ResponseEntity.status(res.getStatusCode()).body(res.bodyTo(String.class))); + assertThat(orgRes.getStatusCode()).isEqualTo(HttpStatus.CREATED); + return jdbcTemplate.queryForObject( + "SELECT id FROM public.organizations WHERE slug = ?", String.class, expectedSlug); + } +} From f86d3ab9d74a05ba38f8d75b9cb3b759ecad4e6a Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Fri, 10 Jul 2026 07:10:35 -0500 Subject: [PATCH 23/72] fix: set MAIL_FROM from the SMTP secret's username Gmail requires the From header to match the authenticated account when no alias is configured. Reads the same secret key as MAIL_USERNAME instead of hardcoding an address, so rotating SMTP credentials never needs a code change. --- ecs/task-definition.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ecs/task-definition.json b/ecs/task-definition.json index f8d75a9f..eb905f72 100644 --- a/ecs/task-definition.json +++ b/ecs/task-definition.json @@ -113,6 +113,10 @@ "name": "MAIL_PASSWORD", "valueFrom": "arn:aws:secretsmanager:us-east-1:418272789689:secret:reqsai/production/smtp-27o6Hf:password::" }, + { + "name": "MAIL_FROM", + "valueFrom": "arn:aws:secretsmanager:us-east-1:418272789689:secret:reqsai/production/smtp-27o6Hf:username::" + }, { "name": "DEEPGRAM_API_KEY", "valueFrom": "arn:aws:secretsmanager:us-east-1:418272789689:secret:reqsai/production/ai-GTSPn8:deepgram_api_key::" From 631209a603d3f5a982da01b968ade2f42a84bedb Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Mon, 6 Jul 2026 19:32:33 -0500 Subject: [PATCH 24/72] docs(changelog): note the integrations jira bounded context and endpoints --- CHANGELOG.md | 34 +++++++++++ gradlew.bat | 164 +++++++++++++++++++++++++-------------------------- 2 files changed, 116 insertions(+), 82 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 66e3aa3f..104f11bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,40 @@ _Bounded-context implementation (iam, billing, workspace, discovery, gateway) in state. The authenticated `orgId` is stashed in the STOMP session attributes on CONNECT so the broker-thread listeners can resolve the tenant. +### Added (Integrations / Jira — `feature/integrations-jira`) + +- **New `integrations` bounded context with a Jira Cloud integration** (ADR-0022). Extensible + provider model (`IntegrationProvider` port + `JiraProvider`) whose credentials live at the + **organization** level and whose push target lives at the **project** level. + - **Org connection endpoints** (org owner/admin gated): `GET /organizations/{orgId}/integrations`, + `POST /organizations/{orgId}/integrations/jira` (`{siteUrl,email,apiToken}` — verifies against Jira + then stores the token **encrypted**; `409 INTEGRATION_ALREADY_CONNECTED` when one already exists), + `POST /organizations/{orgId}/integrations/{connectionId}/test` (`{ok, accountName?}`), + `DELETE /organizations/{orgId}/integrations/{connectionId}` (`204`), + `GET /organizations/{orgId}/integrations/{connectionId}/jira/projects`, + `GET /organizations/{orgId}/integrations/{connectionId}/jira/issue-types?projectKey=`. + The API token is **never** returned by any response. + - **Project target + push endpoints** (project `INTEGRATION_*` gated): + `GET/PUT/DELETE /projects/{projectId}/integration/jira/target` (single target per project; + `404` when none), `POST /projects/{projectId}/integration/jira/stories/{storyId}/push` + (`{storyId,jiraIssueKey,jiraIssueUrl}`; `409 INTEGRATION_TARGET_NOT_CONFIGURED` when no target), + `POST /projects/{projectId}/integration/jira/stories/push-all` (`{results,pushed,failed}` — per-story + failures captured without aborting the batch). All endpoints use header `Api-Version: 1`. + - **Encryption at rest** — AES-256-GCM `AttributeConverter` (random 12-byte IV prepended to the + ciphertext) keyed from `INTEGRATIONS_ENCRYPTION_KEY` (base64 32 bytes); a documented default key is + provided for dev/test so the suite runs without a `.env`. + - **RBAC** — new project permissions `INTEGRATION_READ`, `INTEGRATION_WRITE`, `INTEGRATION_DELETE`, + `INTEGRATION_SYNC` added to the workspace `Permission` catalog. IAM identity/auth is unchanged. + - **Cross-module read** — discovery now publishes a `discovery::api` named interface + (`DiscoveryStoryReadPort` returning value-only `StoryView`s) so integrations can read user stories + (title/role/action/benefit/priority/story points + Given/When/Then) to render the Jira issue + description as ADF. The story is pushed with the `EXPORTED`-style export flow. + - Migration `V21__integration_connections.sql` (tenant schema): `integration_connections` + (org-scoped, one active per org+provider) and `project_integration_targets` (project-scoped, one + per project). New error codes: `INTEGRATION_CONNECTION_NOT_FOUND`, `INTEGRATION_ALREADY_CONNECTED`, + `INTEGRATION_TARGET_NOT_CONFIGURED`, `JIRA_PROJECT_NOT_FOUND`, `JIRA_AUTH_FAILED`, + `JIRA_UNREACHABLE`, `JIRA_PUSH_FAILED`, `INTEGRATION_ENCRYPTION_ERROR`. + ### Added (Backlog / Glossary / Constraints listing — `feature/discovery-session-control`) - **User-story backlog list filters + search** — `GET /projects/{projectId}/stories` now accepts five diff --git a/gradlew.bat b/gradlew.bat index 8508ef68..a51ec4f5 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -1,82 +1,82 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem -@rem SPDX-License-Identifier: Apache-2.0 -@rem - -@if "%DEBUG%"=="" @echo off -@rem ########################################################################## -@rem -@rem gradlew startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables, and ensure extensions are enabled -setlocal EnableExtensions - -set DIRNAME=%~dp0 -if "%DIRNAME%"=="" set DIRNAME=. -@rem This is normally unused -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if %ERRORLEVEL% equ 0 goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -"%COMSPEC%" /c exit 1 - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -"%COMSPEC%" /c exit 1 - -:execute -@rem Setup the command line - - - -@rem Execute gradlew -@rem endlocal doesn't take effect until after the line is parsed and variables are expanded -@rem which allows us to clear the local environment before executing the java command -endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel - -:exitWithErrorLevel -@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts -"%COMSPEC%" /c exit %ERRORLEVEL% +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% From a54e2f1a0e64a479078f6fb27429c91e6a75bf98 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Mon, 6 Jul 2026 19:55:18 -0500 Subject: [PATCH 25/72] refactor(integrations): put secret encryption behind a SecretCipher port and adapter --- .../application/port/SecretCipher.java | 29 +++++++++++++++++++ .../infrastructure/crypto/AesGcmCipher.java | 7 ++++- .../IntegrationsCryptoConfiguration.java | 5 ++-- .../converters/EncryptedStringConverter.java | 14 ++++----- 4 files changed, 45 insertions(+), 10 deletions(-) create mode 100644 src/main/java/com/kntro/reqsai/integrations/application/port/SecretCipher.java diff --git a/src/main/java/com/kntro/reqsai/integrations/application/port/SecretCipher.java b/src/main/java/com/kntro/reqsai/integrations/application/port/SecretCipher.java new file mode 100644 index 00000000..04cb9b1d --- /dev/null +++ b/src/main/java/com/kntro/reqsai/integrations/application/port/SecretCipher.java @@ -0,0 +1,29 @@ +package com.kntro.reqsai.integrations.application.port; + +/** + * Port for symmetric encryption of integration secrets at rest (ADR-0022). + *

+ * Abstracts the cipher used to protect sensitive credentials (e.g. the Jira API token) before they + * are persisted, and to recover them on load. Callers program against this port; the concrete + * algorithm lives in an infrastructure adapter. The stored form is opaque to callers and is + * self-describing to the adapter that produced it. Implementations must never log plaintext or key + * material. + */ +public interface SecretCipher { + + /** + * Encrypts {@code plaintext} into an opaque stored representation. + * + * @param plaintext the secret bytes to protect + * @return the encrypted, self-describing bytes to persist + */ + byte[] encrypt(byte[] plaintext); + + /** + * Decrypts bytes previously produced by {@link #encrypt(byte[])} back into plaintext. + * + * @param stored the stored, encrypted bytes + * @return the recovered plaintext bytes + */ + byte[] decrypt(byte[] stored); +} diff --git a/src/main/java/com/kntro/reqsai/integrations/infrastructure/crypto/AesGcmCipher.java b/src/main/java/com/kntro/reqsai/integrations/infrastructure/crypto/AesGcmCipher.java index fefcc61f..86c94c08 100644 --- a/src/main/java/com/kntro/reqsai/integrations/infrastructure/crypto/AesGcmCipher.java +++ b/src/main/java/com/kntro/reqsai/integrations/infrastructure/crypto/AesGcmCipher.java @@ -1,5 +1,6 @@ package com.kntro.reqsai.integrations.infrastructure.crypto; +import com.kntro.reqsai.integrations.application.port.SecretCipher; import com.kntro.reqsai.integrations.infrastructure.exception.IntegrationsInfrastructureExceptions; import javax.crypto.Cipher; @@ -16,8 +17,10 @@ * self-describing: the stored bytes are {@code IV(12) || ciphertext||tag}. The key is a base64-encoded * 32-byte value supplied at construction (from {@code INTEGRATIONS_ENCRYPTION_KEY}). Never logs * plaintext or key material. + *

+ * Infrastructure adapter for the {@link SecretCipher} application port. */ -public final class AesGcmCipher { +public final class AesGcmCipher implements SecretCipher { private static final String TRANSFORMATION = "AES/GCM/NoPadding"; private static final int IV_LENGTH = 12; @@ -44,6 +47,7 @@ public AesGcmCipher(String base64Key) { } /** Encrypts {@code plaintext} → {@code IV || ciphertext+tag}. */ + @Override public byte[] encrypt(byte[] plaintext) { try { byte[] iv = new byte[IV_LENGTH]; @@ -58,6 +62,7 @@ public byte[] encrypt(byte[] plaintext) { } /** Decrypts {@code IV || ciphertext+tag} produced by {@link #encrypt(byte[])}. */ + @Override public byte[] decrypt(byte[] stored) { try { if (stored.length <= IV_LENGTH) { diff --git a/src/main/java/com/kntro/reqsai/integrations/infrastructure/crypto/IntegrationsCryptoConfiguration.java b/src/main/java/com/kntro/reqsai/integrations/infrastructure/crypto/IntegrationsCryptoConfiguration.java index f4ba3319..fcd98887 100644 --- a/src/main/java/com/kntro/reqsai/integrations/infrastructure/crypto/IntegrationsCryptoConfiguration.java +++ b/src/main/java/com/kntro/reqsai/integrations/infrastructure/crypto/IntegrationsCryptoConfiguration.java @@ -1,5 +1,6 @@ package com.kntro.reqsai.integrations.infrastructure.crypto; +import com.kntro.reqsai.integrations.application.port.SecretCipher; import com.kntro.reqsai.integrations.infrastructure.exception.IntegrationsInfrastructureExceptions; import com.kntro.reqsai.integrations.infrastructure.persistence.converters.EncryptedStringConverter; import jakarta.annotation.PostConstruct; @@ -20,7 +21,7 @@ @Slf4j public class IntegrationsCryptoConfiguration { - private final AesGcmCipher cipher; + private final SecretCipher cipher; public IntegrationsCryptoConfiguration(@Value("${reqsai.integrations.encryption-key:}") String base64Key) { if (base64Key == null || base64Key.isBlank()) { @@ -31,7 +32,7 @@ public IntegrationsCryptoConfiguration(@Value("${reqsai.integrations.encryption- } @Bean - AesGcmCipher integrationsCipher() { + SecretCipher integrationsCipher() { return cipher; } diff --git a/src/main/java/com/kntro/reqsai/integrations/infrastructure/persistence/converters/EncryptedStringConverter.java b/src/main/java/com/kntro/reqsai/integrations/infrastructure/persistence/converters/EncryptedStringConverter.java index ac421fb9..587b8030 100644 --- a/src/main/java/com/kntro/reqsai/integrations/infrastructure/persistence/converters/EncryptedStringConverter.java +++ b/src/main/java/com/kntro/reqsai/integrations/infrastructure/persistence/converters/EncryptedStringConverter.java @@ -1,6 +1,6 @@ package com.kntro.reqsai.integrations.infrastructure.persistence.converters; -import com.kntro.reqsai.integrations.infrastructure.crypto.AesGcmCipher; +import com.kntro.reqsai.integrations.application.port.SecretCipher; import com.kntro.reqsai.integrations.infrastructure.exception.IntegrationsInfrastructureExceptions; import jakarta.persistence.AttributeConverter; import jakarta.persistence.Converter; @@ -12,18 +12,18 @@ * JPA converter that encrypts a {@code String} attribute (the Jira API token) to a {@code byte[]} * ({@code secret_ciphertext} BYTEA) with AES-256-GCM and decrypts it on load (ADR-0022). *

- * JPA converters are instantiated by Hibernate, not Spring, so the {@link AesGcmCipher} is supplied + * JPA converters are instantiated by Hibernate, not Spring, so the {@link SecretCipher} is supplied * through a static holder set once at startup by {@code IntegrationsCryptoConfiguration}. A missing * cipher (no key configured) surfaces as {@code INTEGRATION_ENCRYPTION_ERROR} rather than a null token. */ @Converter public class EncryptedStringConverter implements AttributeConverter { - private static volatile @Nullable AesGcmCipher cipher; + private static volatile @Nullable SecretCipher cipher; /** Wired once at startup by the crypto configuration. */ - public static void setCipher(AesGcmCipher aesGcmCipher) { - cipher = aesGcmCipher; + public static void setCipher(SecretCipher secretCipher) { + cipher = secretCipher; } @Override @@ -42,8 +42,8 @@ public static void setCipher(AesGcmCipher aesGcmCipher) { return new String(cipher().decrypt(dbData), StandardCharsets.UTF_8); } - private static AesGcmCipher cipher() { - AesGcmCipher c = cipher; + private static SecretCipher cipher() { + SecretCipher c = cipher; if (c == null) { throw IntegrationsInfrastructureExceptions.encryptionError( "encryption cipher is not configured (INTEGRATIONS_ENCRYPTION_KEY missing)", null); From ca7c52723c1efc379b88f27b7c59323fc2946f12 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Mon, 6 Jul 2026 20:00:26 -0500 Subject: [PATCH 26/72] refactor(gateway): relocate the jira integration into the reserved gateway bounded context --- CHANGELOG.md | 5 ++- .../adr/0023-third-party-integrations-jira.md | 17 ++++---- .../command/ConnectJiraCommand.java | 2 +- .../command/DeleteConnectionCommand.java | 2 +- .../command/DeleteProjectTargetCommand.java | 2 +- .../command/PushAllStoriesCommand.java | 2 +- .../application/command/PushStoryCommand.java | 2 +- .../command/SaveProjectTargetCommand.java | 2 +- .../handler/ConnectJiraCommandHandler.java | 18 ++++---- .../DeleteConnectionCommandHandler.java | 10 ++--- .../DeleteProjectTargetCommandHandler.java | 10 ++--- .../handler/GetProjectTargetQueryHandler.java | 10 ++--- .../handler/ListConnectionsQueryHandler.java | 8 ++-- .../ListJiraIssueTypesQueryHandler.java | 18 ++++---- .../handler/ListJiraProjectsQueryHandler.java | 18 ++++---- .../handler/PushAllStoriesCommandHandler.java | 20 ++++----- .../handler/PushStoryCommandHandler.java | 18 ++++---- .../SaveProjectTargetCommandHandler.java | 12 +++--- .../handler/TestConnectionQueryHandler.java | 18 ++++---- .../port/IntegrationConnectionRepository.java | 8 ++-- .../application/port/IntegrationProvider.java | 4 +- .../ProjectIntegrationTargetRepository.java | 4 +- .../application/port/SecretCipher.java | 2 +- .../query/GetProjectTargetQuery.java | 2 +- .../query/ListConnectionsQuery.java | 2 +- .../query/ListJiraIssueTypesQuery.java | 2 +- .../query/ListJiraProjectsQuery.java | 2 +- .../query/TestConnectionQuery.java | 2 +- .../application/result/BatchPushResult.java | 2 +- .../result/ConnectionTestResult.java | 2 +- .../application/result/StoryPushResult.java | 2 +- .../service/ProviderCredentialsFactory.java | 6 +-- .../application/service/ProviderRegistry.java | 6 +-- .../application/service/StoryPushService.java | 16 +++---- .../domain/exception/IntegrationsError.java | 4 +- .../exception/IntegrationsExceptions.java | 2 +- .../domain/model/ConnectionStatus.java | 2 +- .../domain/model/IntegrationConnection.java | 4 +- .../domain/model/IntegrationProviderType.java | 2 +- .../model/ProjectIntegrationTarget.java | 2 +- .../infrastructure/crypto/AesGcmCipher.java | 6 +-- .../IntegrationsCryptoConfiguration.java | 8 ++-- .../IntegrationsInfrastructureError.java | 4 +- .../IntegrationsInfrastructureExceptions.java | 4 +- .../infrastructure/jira/JiraAdfBuilder.java | 2 +- .../infrastructure/jira/JiraClient.java | 4 +- .../infrastructure/jira/JiraProvider.java | 6 +-- ...ntegrationConnectionRepositoryAdapter.java | 12 +++--- ...ectIntegrationTargetRepositoryAdapter.java | 8 ++-- .../converters/EncryptedStringConverter.java | 6 +-- .../IntegrationConnectionJpaRepository.java | 8 ++-- ...ProjectIntegrationTargetJpaRepository.java | 4 +- ...OrganizationIntegrationControllerImpl.java | 42 +++++++++---------- .../ProjectIntegrationControllerImpl.java | 34 +++++++-------- .../rest/dto/request/ConnectJiraRequest.java | 2 +- .../dto/request/SaveProjectTargetRequest.java | 2 +- .../rest/dto/response/BatchPushResponse.java | 2 +- .../dto/response/ConnectionTestResponse.java | 2 +- .../IntegrationConnectionResponse.java | 2 +- .../dto/response/JiraIssueTypeResponse.java | 2 +- .../dto/response/JiraProjectResponse.java | 2 +- .../dto/response/JiraPushResultResponse.java | 2 +- .../response/ProjectJiraTargetResponse.java | 2 +- .../request/IntegrationRequestMapper.java | 10 ++--- .../response/IntegrationResponseMapper.java | 30 ++++++------- .../OrganizationIntegrationController.java | 12 +++--- .../swagger/ProjectIntegrationController.java | 10 ++--- .../kntro/reqsai/gateway/package-info.java | 17 +++++--- .../reqsai/integrations/package-info.java | 15 ------- .../architecture/ArchitectureTests.java | 4 +- .../StubJiraProviderConfig.java | 6 +-- .../ConnectJiraCommandHandlerTest.java | 20 ++++----- .../PushAllStoriesCommandHandlerTest.java | 30 ++++++------- .../handler/PushStoryCommandHandlerTest.java | 26 ++++++------ .../TestConnectionQueryHandlerTest.java | 22 +++++----- .../crypto/AesGcmCipherTest.java | 2 +- .../jira/JiraAdfBuilderTest.java | 2 +- .../JiraIntegrationPushIntegrationTest.java | 4 +- 78 files changed, 321 insertions(+), 327 deletions(-) rename src/main/java/com/kntro/reqsai/{integrations => gateway}/application/command/ConnectJiraCommand.java (83%) rename src/main/java/com/kntro/reqsai/{integrations => gateway}/application/command/DeleteConnectionCommand.java (75%) rename src/main/java/com/kntro/reqsai/{integrations => gateway}/application/command/DeleteProjectTargetCommand.java (71%) rename src/main/java/com/kntro/reqsai/{integrations => gateway}/application/command/PushAllStoriesCommand.java (77%) rename src/main/java/com/kntro/reqsai/{integrations => gateway}/application/command/PushStoryCommand.java (75%) rename src/main/java/com/kntro/reqsai/{integrations => gateway}/application/command/SaveProjectTargetCommand.java (82%) rename src/main/java/com/kntro/reqsai/{integrations => gateway}/application/handler/ConnectJiraCommandHandler.java (72%) rename src/main/java/com/kntro/reqsai/{integrations => gateway}/application/handler/DeleteConnectionCommandHandler.java (68%) rename src/main/java/com/kntro/reqsai/{integrations => gateway}/application/handler/DeleteProjectTargetCommandHandler.java (63%) rename src/main/java/com/kntro/reqsai/{integrations => gateway}/application/handler/GetProjectTargetQueryHandler.java (63%) rename src/main/java/com/kntro/reqsai/{integrations => gateway}/application/handler/ListConnectionsQueryHandler.java (67%) rename src/main/java/com/kntro/reqsai/{integrations => gateway}/application/handler/ListJiraIssueTypesQueryHandler.java (60%) rename src/main/java/com/kntro/reqsai/{integrations => gateway}/application/handler/ListJiraProjectsQueryHandler.java (59%) rename src/main/java/com/kntro/reqsai/{integrations => gateway}/application/handler/PushAllStoriesCommandHandler.java (72%) rename src/main/java/com/kntro/reqsai/{integrations => gateway}/application/handler/PushStoryCommandHandler.java (67%) rename src/main/java/com/kntro/reqsai/{integrations => gateway}/application/handler/SaveProjectTargetCommandHandler.java (74%) rename src/main/java/com/kntro/reqsai/{integrations => gateway}/application/handler/TestConnectionQueryHandler.java (72%) rename src/main/java/com/kntro/reqsai/{integrations => gateway}/application/port/IntegrationConnectionRepository.java (72%) rename src/main/java/com/kntro/reqsai/{integrations => gateway}/application/port/IntegrationProvider.java (93%) rename src/main/java/com/kntro/reqsai/{integrations => gateway}/application/port/ProjectIntegrationTargetRepository.java (74%) rename src/main/java/com/kntro/reqsai/{integrations => gateway}/application/port/SecretCipher.java (94%) rename src/main/java/com/kntro/reqsai/{integrations => gateway}/application/query/GetProjectTargetQuery.java (71%) rename src/main/java/com/kntro/reqsai/{integrations => gateway}/application/query/ListConnectionsQuery.java (73%) rename src/main/java/com/kntro/reqsai/{integrations => gateway}/application/query/ListJiraIssueTypesQuery.java (80%) rename src/main/java/com/kntro/reqsai/{integrations => gateway}/application/query/ListJiraProjectsQuery.java (77%) rename src/main/java/com/kntro/reqsai/{integrations => gateway}/application/query/TestConnectionQuery.java (77%) rename src/main/java/com/kntro/reqsai/{integrations => gateway}/application/result/BatchPushResult.java (88%) rename src/main/java/com/kntro/reqsai/{integrations => gateway}/application/result/ConnectionTestResult.java (79%) rename src/main/java/com/kntro/reqsai/{integrations => gateway}/application/result/StoryPushResult.java (93%) rename src/main/java/com/kntro/reqsai/{integrations => gateway}/application/service/ProviderCredentialsFactory.java (72%) rename src/main/java/com/kntro/reqsai/{integrations => gateway}/application/service/ProviderRegistry.java (83%) rename src/main/java/com/kntro/reqsai/{integrations => gateway}/application/service/StoryPushService.java (73%) rename src/main/java/com/kntro/reqsai/{integrations => gateway}/domain/exception/IntegrationsError.java (83%) rename src/main/java/com/kntro/reqsai/{integrations => gateway}/domain/exception/IntegrationsExceptions.java (97%) rename src/main/java/com/kntro/reqsai/{integrations => gateway}/domain/model/ConnectionStatus.java (93%) rename src/main/java/com/kntro/reqsai/{integrations => gateway}/domain/model/IntegrationConnection.java (96%) rename src/main/java/com/kntro/reqsai/{integrations => gateway}/domain/model/IntegrationProviderType.java (85%) rename src/main/java/com/kntro/reqsai/{integrations => gateway}/domain/model/ProjectIntegrationTarget.java (97%) rename src/main/java/com/kntro/reqsai/{integrations => gateway}/infrastructure/crypto/AesGcmCipher.java (93%) rename src/main/java/com/kntro/reqsai/{integrations => gateway}/infrastructure/crypto/IntegrationsCryptoConfiguration.java (81%) rename src/main/java/com/kntro/reqsai/{integrations => gateway}/infrastructure/exception/IntegrationsInfrastructureError.java (86%) rename src/main/java/com/kntro/reqsai/{integrations => gateway}/infrastructure/exception/IntegrationsInfrastructureExceptions.java (90%) rename src/main/java/com/kntro/reqsai/{integrations => gateway}/infrastructure/jira/JiraAdfBuilder.java (97%) rename src/main/java/com/kntro/reqsai/{integrations => gateway}/infrastructure/jira/JiraClient.java (97%) rename src/main/java/com/kntro/reqsai/{integrations => gateway}/infrastructure/jira/JiraProvider.java (89%) rename src/main/java/com/kntro/reqsai/{integrations => gateway}/infrastructure/persistence/adapters/IntegrationConnectionRepositoryAdapter.java (74%) rename src/main/java/com/kntro/reqsai/{integrations => gateway}/infrastructure/persistence/adapters/ProjectIntegrationTargetRepositoryAdapter.java (69%) rename src/main/java/com/kntro/reqsai/{integrations => gateway}/infrastructure/persistence/converters/EncryptedStringConverter.java (88%) rename src/main/java/com/kntro/reqsai/{integrations => gateway}/infrastructure/persistence/repositories/IntegrationConnectionJpaRepository.java (66%) rename src/main/java/com/kntro/reqsai/{integrations => gateway}/infrastructure/persistence/repositories/ProjectIntegrationTargetJpaRepository.java (66%) rename src/main/java/com/kntro/reqsai/{integrations => gateway}/interfaces/rest/controllers/OrganizationIntegrationControllerImpl.java (71%) rename src/main/java/com/kntro/reqsai/{integrations => gateway}/interfaces/rest/controllers/ProjectIntegrationControllerImpl.java (69%) rename src/main/java/com/kntro/reqsai/{integrations => gateway}/interfaces/rest/dto/request/ConnectJiraRequest.java (92%) rename src/main/java/com/kntro/reqsai/{integrations => gateway}/interfaces/rest/dto/request/SaveProjectTargetRequest.java (91%) rename src/main/java/com/kntro/reqsai/{integrations => gateway}/interfaces/rest/dto/response/BatchPushResponse.java (82%) rename src/main/java/com/kntro/reqsai/{integrations => gateway}/interfaces/rest/dto/response/ConnectionTestResponse.java (82%) rename src/main/java/com/kntro/reqsai/{integrations => gateway}/interfaces/rest/dto/response/IntegrationConnectionResponse.java (89%) rename src/main/java/com/kntro/reqsai/{integrations => gateway}/interfaces/rest/dto/response/JiraIssueTypeResponse.java (76%) rename src/main/java/com/kntro/reqsai/{integrations => gateway}/interfaces/rest/dto/response/JiraProjectResponse.java (75%) rename src/main/java/com/kntro/reqsai/{integrations => gateway}/interfaces/rest/dto/response/JiraPushResultResponse.java (87%) rename src/main/java/com/kntro/reqsai/{integrations => gateway}/interfaces/rest/dto/response/ProjectJiraTargetResponse.java (86%) rename src/main/java/com/kntro/reqsai/{integrations => gateway}/interfaces/rest/mappers/request/IntegrationRequestMapper.java (66%) rename src/main/java/com/kntro/reqsai/{integrations => gateway}/interfaces/rest/mappers/response/IntegrationResponseMapper.java (61%) rename src/main/java/com/kntro/reqsai/{integrations => gateway}/interfaces/rest/swagger/OrganizationIntegrationController.java (93%) rename src/main/java/com/kntro/reqsai/{integrations => gateway}/interfaces/rest/swagger/ProjectIntegrationController.java (93%) delete mode 100644 src/main/java/com/kntro/reqsai/integrations/package-info.java rename src/test/java/com/kntro/reqsai/{integrations => gateway}/StubJiraProviderConfig.java (90%) rename src/test/java/com/kntro/reqsai/{integrations => gateway}/application/handler/ConnectJiraCommandHandlerTest.java (84%) rename src/test/java/com/kntro/reqsai/{integrations => gateway}/application/handler/PushAllStoriesCommandHandlerTest.java (75%) rename src/test/java/com/kntro/reqsai/{integrations => gateway}/application/handler/PushStoryCommandHandlerTest.java (78%) rename src/test/java/com/kntro/reqsai/{integrations => gateway}/application/handler/TestConnectionQueryHandlerTest.java (79%) rename src/test/java/com/kntro/reqsai/{integrations => gateway}/infrastructure/crypto/AesGcmCipherTest.java (97%) rename src/test/java/com/kntro/reqsai/{integrations => gateway}/infrastructure/jira/JiraAdfBuilderTest.java (97%) rename src/test/java/com/kntro/reqsai/{integrations => gateway}/interfaces/rest/JiraIntegrationPushIntegrationTest.java (98%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 104f11bc..d1ff63c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,7 +30,8 @@ _Bounded-context implementation (iam, billing, workspace, discovery, gateway) in ### Added (Integrations / Jira — `feature/integrations-jira`) -- **New `integrations` bounded context with a Jira Cloud integration** (ADR-0022). Extensible +- **Jira Cloud integration in the reserved `gateway` bounded context** (ADR-0022) — the feature reuses + the `com.kntro.reqsai.gateway` module reserved for external integrations. Extensible provider model (`IntegrationProvider` port + `JiraProvider`) whose credentials live at the **organization** level and whose push target lives at the **project** level. - **Org connection endpoints** (org owner/admin gated): `GET /organizations/{orgId}/integrations`, @@ -53,7 +54,7 @@ _Bounded-context implementation (iam, billing, workspace, discovery, gateway) in - **RBAC** — new project permissions `INTEGRATION_READ`, `INTEGRATION_WRITE`, `INTEGRATION_DELETE`, `INTEGRATION_SYNC` added to the workspace `Permission` catalog. IAM identity/auth is unchanged. - **Cross-module read** — discovery now publishes a `discovery::api` named interface - (`DiscoveryStoryReadPort` returning value-only `StoryView`s) so integrations can read user stories + (`DiscoveryStoryReadPort` returning value-only `StoryView`s) so the `gateway` module can read user stories (title/role/action/benefit/priority/story points + Given/When/Then) to render the Jira issue description as ADF. The story is pushed with the `EXPORTED`-style export flow. - Migration `V21__integration_connections.sql` (tenant schema): `integration_connections` diff --git a/docs/adr/0023-third-party-integrations-jira.md b/docs/adr/0023-third-party-integrations-jira.md index 8edafd18..c6d39dfd 100644 --- a/docs/adr/0023-third-party-integrations-jira.md +++ b/docs/adr/0023-third-party-integrations-jira.md @@ -37,14 +37,15 @@ Forces: ## Decision -### A new `integrations` bounded context - -Introduce `com.kntro.reqsai.integrations` as its own Spring Modulith application module -(`@ApplicationModule(allowedDependencies = {"shared", "workspace::api", "discovery::api"})`), with the -usual hexagonal layers (`domain`, `application`, `infrastructure`, `interfaces`) plus an `api` -named-interface package reserved for future cross-module exposure. It depends on `workspace::api` for -org/project authorization context and on a new `discovery::api` named interface for reading the -stories it pushes. +### Housed in the reserved `gateway` bounded context + +The integration lives in `com.kntro.reqsai.gateway`, the Spring Modulith application module reserved +for external integrations (`@ApplicationModule(allowedDependencies = {"shared", "workspace::api", +"discovery::api"})`), with the usual hexagonal layers (`domain`, `application`, `infrastructure`, +`interfaces`). It depends on `workspace::api` for org/project authorization context and on a new +`discovery::api` named interface for reading the stories it pushes. The Jira feature keeps its own +domain vocabulary (`IntegrationConnection`, `ProjectIntegrationTarget`, etc.) — those name the +concept, while `gateway` names the module. ### Org-level connection, project-level target (the split) diff --git a/src/main/java/com/kntro/reqsai/integrations/application/command/ConnectJiraCommand.java b/src/main/java/com/kntro/reqsai/gateway/application/command/ConnectJiraCommand.java similarity index 83% rename from src/main/java/com/kntro/reqsai/integrations/application/command/ConnectJiraCommand.java rename to src/main/java/com/kntro/reqsai/gateway/application/command/ConnectJiraCommand.java index 209f199e..c5786c0d 100644 --- a/src/main/java/com/kntro/reqsai/integrations/application/command/ConnectJiraCommand.java +++ b/src/main/java/com/kntro/reqsai/gateway/application/command/ConnectJiraCommand.java @@ -1,4 +1,4 @@ -package com.kntro.reqsai.integrations.application.command; +package com.kntro.reqsai.gateway.application.command; import java.util.UUID; diff --git a/src/main/java/com/kntro/reqsai/integrations/application/command/DeleteConnectionCommand.java b/src/main/java/com/kntro/reqsai/gateway/application/command/DeleteConnectionCommand.java similarity index 75% rename from src/main/java/com/kntro/reqsai/integrations/application/command/DeleteConnectionCommand.java rename to src/main/java/com/kntro/reqsai/gateway/application/command/DeleteConnectionCommand.java index 0388d349..51a2e435 100644 --- a/src/main/java/com/kntro/reqsai/integrations/application/command/DeleteConnectionCommand.java +++ b/src/main/java/com/kntro/reqsai/gateway/application/command/DeleteConnectionCommand.java @@ -1,4 +1,4 @@ -package com.kntro.reqsai.integrations.application.command; +package com.kntro.reqsai.gateway.application.command; import java.util.UUID; diff --git a/src/main/java/com/kntro/reqsai/integrations/application/command/DeleteProjectTargetCommand.java b/src/main/java/com/kntro/reqsai/gateway/application/command/DeleteProjectTargetCommand.java similarity index 71% rename from src/main/java/com/kntro/reqsai/integrations/application/command/DeleteProjectTargetCommand.java rename to src/main/java/com/kntro/reqsai/gateway/application/command/DeleteProjectTargetCommand.java index 425830ac..7b265298 100644 --- a/src/main/java/com/kntro/reqsai/integrations/application/command/DeleteProjectTargetCommand.java +++ b/src/main/java/com/kntro/reqsai/gateway/application/command/DeleteProjectTargetCommand.java @@ -1,4 +1,4 @@ -package com.kntro.reqsai.integrations.application.command; +package com.kntro.reqsai.gateway.application.command; import java.util.UUID; diff --git a/src/main/java/com/kntro/reqsai/integrations/application/command/PushAllStoriesCommand.java b/src/main/java/com/kntro/reqsai/gateway/application/command/PushAllStoriesCommand.java similarity index 77% rename from src/main/java/com/kntro/reqsai/integrations/application/command/PushAllStoriesCommand.java rename to src/main/java/com/kntro/reqsai/gateway/application/command/PushAllStoriesCommand.java index 4f5d2e72..48f3827c 100644 --- a/src/main/java/com/kntro/reqsai/integrations/application/command/PushAllStoriesCommand.java +++ b/src/main/java/com/kntro/reqsai/gateway/application/command/PushAllStoriesCommand.java @@ -1,4 +1,4 @@ -package com.kntro.reqsai.integrations.application.command; +package com.kntro.reqsai.gateway.application.command; import java.util.UUID; diff --git a/src/main/java/com/kntro/reqsai/integrations/application/command/PushStoryCommand.java b/src/main/java/com/kntro/reqsai/gateway/application/command/PushStoryCommand.java similarity index 75% rename from src/main/java/com/kntro/reqsai/integrations/application/command/PushStoryCommand.java rename to src/main/java/com/kntro/reqsai/gateway/application/command/PushStoryCommand.java index 9834b519..146bbf41 100644 --- a/src/main/java/com/kntro/reqsai/integrations/application/command/PushStoryCommand.java +++ b/src/main/java/com/kntro/reqsai/gateway/application/command/PushStoryCommand.java @@ -1,4 +1,4 @@ -package com.kntro.reqsai.integrations.application.command; +package com.kntro.reqsai.gateway.application.command; import java.util.UUID; diff --git a/src/main/java/com/kntro/reqsai/integrations/application/command/SaveProjectTargetCommand.java b/src/main/java/com/kntro/reqsai/gateway/application/command/SaveProjectTargetCommand.java similarity index 82% rename from src/main/java/com/kntro/reqsai/integrations/application/command/SaveProjectTargetCommand.java rename to src/main/java/com/kntro/reqsai/gateway/application/command/SaveProjectTargetCommand.java index eff8a043..b6c6eb9c 100644 --- a/src/main/java/com/kntro/reqsai/integrations/application/command/SaveProjectTargetCommand.java +++ b/src/main/java/com/kntro/reqsai/gateway/application/command/SaveProjectTargetCommand.java @@ -1,4 +1,4 @@ -package com.kntro.reqsai.integrations.application.command; +package com.kntro.reqsai.gateway.application.command; import java.util.UUID; diff --git a/src/main/java/com/kntro/reqsai/integrations/application/handler/ConnectJiraCommandHandler.java b/src/main/java/com/kntro/reqsai/gateway/application/handler/ConnectJiraCommandHandler.java similarity index 72% rename from src/main/java/com/kntro/reqsai/integrations/application/handler/ConnectJiraCommandHandler.java rename to src/main/java/com/kntro/reqsai/gateway/application/handler/ConnectJiraCommandHandler.java index 0373ff5b..040c3b96 100644 --- a/src/main/java/com/kntro/reqsai/integrations/application/handler/ConnectJiraCommandHandler.java +++ b/src/main/java/com/kntro/reqsai/gateway/application/handler/ConnectJiraCommandHandler.java @@ -1,13 +1,13 @@ -package com.kntro.reqsai.integrations.application.handler; +package com.kntro.reqsai.gateway.application.handler; -import com.kntro.reqsai.integrations.application.command.ConnectJiraCommand; -import com.kntro.reqsai.integrations.application.port.IntegrationConnectionRepository; -import com.kntro.reqsai.integrations.application.port.IntegrationProvider; -import com.kntro.reqsai.integrations.application.service.ProviderRegistry; -import com.kntro.reqsai.integrations.domain.exception.IntegrationsExceptions; -import com.kntro.reqsai.integrations.domain.model.ConnectionStatus; -import com.kntro.reqsai.integrations.domain.model.IntegrationConnection; -import com.kntro.reqsai.integrations.domain.model.IntegrationProviderType; +import com.kntro.reqsai.gateway.application.command.ConnectJiraCommand; +import com.kntro.reqsai.gateway.application.port.IntegrationConnectionRepository; +import com.kntro.reqsai.gateway.application.port.IntegrationProvider; +import com.kntro.reqsai.gateway.application.service.ProviderRegistry; +import com.kntro.reqsai.gateway.domain.exception.IntegrationsExceptions; +import com.kntro.reqsai.gateway.domain.model.ConnectionStatus; +import com.kntro.reqsai.gateway.domain.model.IntegrationConnection; +import com.kntro.reqsai.gateway.domain.model.IntegrationProviderType; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; diff --git a/src/main/java/com/kntro/reqsai/integrations/application/handler/DeleteConnectionCommandHandler.java b/src/main/java/com/kntro/reqsai/gateway/application/handler/DeleteConnectionCommandHandler.java similarity index 68% rename from src/main/java/com/kntro/reqsai/integrations/application/handler/DeleteConnectionCommandHandler.java rename to src/main/java/com/kntro/reqsai/gateway/application/handler/DeleteConnectionCommandHandler.java index ef8ae752..90acdbb4 100644 --- a/src/main/java/com/kntro/reqsai/integrations/application/handler/DeleteConnectionCommandHandler.java +++ b/src/main/java/com/kntro/reqsai/gateway/application/handler/DeleteConnectionCommandHandler.java @@ -1,9 +1,9 @@ -package com.kntro.reqsai.integrations.application.handler; +package com.kntro.reqsai.gateway.application.handler; -import com.kntro.reqsai.integrations.application.command.DeleteConnectionCommand; -import com.kntro.reqsai.integrations.application.port.IntegrationConnectionRepository; -import com.kntro.reqsai.integrations.domain.exception.IntegrationsExceptions; -import com.kntro.reqsai.integrations.domain.model.IntegrationConnection; +import com.kntro.reqsai.gateway.application.command.DeleteConnectionCommand; +import com.kntro.reqsai.gateway.application.port.IntegrationConnectionRepository; +import com.kntro.reqsai.gateway.domain.exception.IntegrationsExceptions; +import com.kntro.reqsai.gateway.domain.model.IntegrationConnection; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; diff --git a/src/main/java/com/kntro/reqsai/integrations/application/handler/DeleteProjectTargetCommandHandler.java b/src/main/java/com/kntro/reqsai/gateway/application/handler/DeleteProjectTargetCommandHandler.java similarity index 63% rename from src/main/java/com/kntro/reqsai/integrations/application/handler/DeleteProjectTargetCommandHandler.java rename to src/main/java/com/kntro/reqsai/gateway/application/handler/DeleteProjectTargetCommandHandler.java index 39a3e976..96d523d2 100644 --- a/src/main/java/com/kntro/reqsai/integrations/application/handler/DeleteProjectTargetCommandHandler.java +++ b/src/main/java/com/kntro/reqsai/gateway/application/handler/DeleteProjectTargetCommandHandler.java @@ -1,9 +1,9 @@ -package com.kntro.reqsai.integrations.application.handler; +package com.kntro.reqsai.gateway.application.handler; -import com.kntro.reqsai.integrations.application.command.DeleteProjectTargetCommand; -import com.kntro.reqsai.integrations.application.port.ProjectIntegrationTargetRepository; -import com.kntro.reqsai.integrations.domain.exception.IntegrationsExceptions; -import com.kntro.reqsai.integrations.domain.model.ProjectIntegrationTarget; +import com.kntro.reqsai.gateway.application.command.DeleteProjectTargetCommand; +import com.kntro.reqsai.gateway.application.port.ProjectIntegrationTargetRepository; +import com.kntro.reqsai.gateway.domain.exception.IntegrationsExceptions; +import com.kntro.reqsai.gateway.domain.model.ProjectIntegrationTarget; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; diff --git a/src/main/java/com/kntro/reqsai/integrations/application/handler/GetProjectTargetQueryHandler.java b/src/main/java/com/kntro/reqsai/gateway/application/handler/GetProjectTargetQueryHandler.java similarity index 63% rename from src/main/java/com/kntro/reqsai/integrations/application/handler/GetProjectTargetQueryHandler.java rename to src/main/java/com/kntro/reqsai/gateway/application/handler/GetProjectTargetQueryHandler.java index 0d4e5a3e..eb8232cb 100644 --- a/src/main/java/com/kntro/reqsai/integrations/application/handler/GetProjectTargetQueryHandler.java +++ b/src/main/java/com/kntro/reqsai/gateway/application/handler/GetProjectTargetQueryHandler.java @@ -1,9 +1,9 @@ -package com.kntro.reqsai.integrations.application.handler; +package com.kntro.reqsai.gateway.application.handler; -import com.kntro.reqsai.integrations.application.port.ProjectIntegrationTargetRepository; -import com.kntro.reqsai.integrations.application.query.GetProjectTargetQuery; -import com.kntro.reqsai.integrations.domain.exception.IntegrationsExceptions; -import com.kntro.reqsai.integrations.domain.model.ProjectIntegrationTarget; +import com.kntro.reqsai.gateway.application.port.ProjectIntegrationTargetRepository; +import com.kntro.reqsai.gateway.application.query.GetProjectTargetQuery; +import com.kntro.reqsai.gateway.domain.exception.IntegrationsExceptions; +import com.kntro.reqsai.gateway.domain.model.ProjectIntegrationTarget; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; diff --git a/src/main/java/com/kntro/reqsai/integrations/application/handler/ListConnectionsQueryHandler.java b/src/main/java/com/kntro/reqsai/gateway/application/handler/ListConnectionsQueryHandler.java similarity index 67% rename from src/main/java/com/kntro/reqsai/integrations/application/handler/ListConnectionsQueryHandler.java rename to src/main/java/com/kntro/reqsai/gateway/application/handler/ListConnectionsQueryHandler.java index c6d1dde2..9c3e2f6b 100644 --- a/src/main/java/com/kntro/reqsai/integrations/application/handler/ListConnectionsQueryHandler.java +++ b/src/main/java/com/kntro/reqsai/gateway/application/handler/ListConnectionsQueryHandler.java @@ -1,8 +1,8 @@ -package com.kntro.reqsai.integrations.application.handler; +package com.kntro.reqsai.gateway.application.handler; -import com.kntro.reqsai.integrations.application.port.IntegrationConnectionRepository; -import com.kntro.reqsai.integrations.application.query.ListConnectionsQuery; -import com.kntro.reqsai.integrations.domain.model.IntegrationConnection; +import com.kntro.reqsai.gateway.application.port.IntegrationConnectionRepository; +import com.kntro.reqsai.gateway.application.query.ListConnectionsQuery; +import com.kntro.reqsai.gateway.domain.model.IntegrationConnection; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; diff --git a/src/main/java/com/kntro/reqsai/integrations/application/handler/ListJiraIssueTypesQueryHandler.java b/src/main/java/com/kntro/reqsai/gateway/application/handler/ListJiraIssueTypesQueryHandler.java similarity index 60% rename from src/main/java/com/kntro/reqsai/integrations/application/handler/ListJiraIssueTypesQueryHandler.java rename to src/main/java/com/kntro/reqsai/gateway/application/handler/ListJiraIssueTypesQueryHandler.java index e24f5c84..7adf8c6a 100644 --- a/src/main/java/com/kntro/reqsai/integrations/application/handler/ListJiraIssueTypesQueryHandler.java +++ b/src/main/java/com/kntro/reqsai/gateway/application/handler/ListJiraIssueTypesQueryHandler.java @@ -1,13 +1,13 @@ -package com.kntro.reqsai.integrations.application.handler; +package com.kntro.reqsai.gateway.application.handler; -import com.kntro.reqsai.integrations.application.port.IntegrationConnectionRepository; -import com.kntro.reqsai.integrations.application.port.IntegrationProvider; -import com.kntro.reqsai.integrations.application.port.IntegrationProvider.RemoteIssueType; -import com.kntro.reqsai.integrations.application.query.ListJiraIssueTypesQuery; -import com.kntro.reqsai.integrations.application.service.ProviderCredentialsFactory; -import com.kntro.reqsai.integrations.application.service.ProviderRegistry; -import com.kntro.reqsai.integrations.domain.exception.IntegrationsExceptions; -import com.kntro.reqsai.integrations.domain.model.IntegrationConnection; +import com.kntro.reqsai.gateway.application.port.IntegrationConnectionRepository; +import com.kntro.reqsai.gateway.application.port.IntegrationProvider; +import com.kntro.reqsai.gateway.application.port.IntegrationProvider.RemoteIssueType; +import com.kntro.reqsai.gateway.application.query.ListJiraIssueTypesQuery; +import com.kntro.reqsai.gateway.application.service.ProviderCredentialsFactory; +import com.kntro.reqsai.gateway.application.service.ProviderRegistry; +import com.kntro.reqsai.gateway.domain.exception.IntegrationsExceptions; +import com.kntro.reqsai.gateway.domain.model.IntegrationConnection; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; diff --git a/src/main/java/com/kntro/reqsai/integrations/application/handler/ListJiraProjectsQueryHandler.java b/src/main/java/com/kntro/reqsai/gateway/application/handler/ListJiraProjectsQueryHandler.java similarity index 59% rename from src/main/java/com/kntro/reqsai/integrations/application/handler/ListJiraProjectsQueryHandler.java rename to src/main/java/com/kntro/reqsai/gateway/application/handler/ListJiraProjectsQueryHandler.java index dec30e02..4caabf5d 100644 --- a/src/main/java/com/kntro/reqsai/integrations/application/handler/ListJiraProjectsQueryHandler.java +++ b/src/main/java/com/kntro/reqsai/gateway/application/handler/ListJiraProjectsQueryHandler.java @@ -1,13 +1,13 @@ -package com.kntro.reqsai.integrations.application.handler; +package com.kntro.reqsai.gateway.application.handler; -import com.kntro.reqsai.integrations.application.port.IntegrationConnectionRepository; -import com.kntro.reqsai.integrations.application.port.IntegrationProvider; -import com.kntro.reqsai.integrations.application.port.IntegrationProvider.RemoteProject; -import com.kntro.reqsai.integrations.application.query.ListJiraProjectsQuery; -import com.kntro.reqsai.integrations.application.service.ProviderCredentialsFactory; -import com.kntro.reqsai.integrations.application.service.ProviderRegistry; -import com.kntro.reqsai.integrations.domain.exception.IntegrationsExceptions; -import com.kntro.reqsai.integrations.domain.model.IntegrationConnection; +import com.kntro.reqsai.gateway.application.port.IntegrationConnectionRepository; +import com.kntro.reqsai.gateway.application.port.IntegrationProvider; +import com.kntro.reqsai.gateway.application.port.IntegrationProvider.RemoteProject; +import com.kntro.reqsai.gateway.application.query.ListJiraProjectsQuery; +import com.kntro.reqsai.gateway.application.service.ProviderCredentialsFactory; +import com.kntro.reqsai.gateway.application.service.ProviderRegistry; +import com.kntro.reqsai.gateway.domain.exception.IntegrationsExceptions; +import com.kntro.reqsai.gateway.domain.model.IntegrationConnection; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; diff --git a/src/main/java/com/kntro/reqsai/integrations/application/handler/PushAllStoriesCommandHandler.java b/src/main/java/com/kntro/reqsai/gateway/application/handler/PushAllStoriesCommandHandler.java similarity index 72% rename from src/main/java/com/kntro/reqsai/integrations/application/handler/PushAllStoriesCommandHandler.java rename to src/main/java/com/kntro/reqsai/gateway/application/handler/PushAllStoriesCommandHandler.java index f0aa11e2..5042b395 100644 --- a/src/main/java/com/kntro/reqsai/integrations/application/handler/PushAllStoriesCommandHandler.java +++ b/src/main/java/com/kntro/reqsai/gateway/application/handler/PushAllStoriesCommandHandler.java @@ -1,16 +1,16 @@ -package com.kntro.reqsai.integrations.application.handler; +package com.kntro.reqsai.gateway.application.handler; import com.kntro.reqsai.discovery.api.DiscoveryStoryReadPort; import com.kntro.reqsai.discovery.api.StoryView; -import com.kntro.reqsai.integrations.application.command.PushAllStoriesCommand; -import com.kntro.reqsai.integrations.application.port.IntegrationProvider.PushedIssue; -import com.kntro.reqsai.integrations.application.port.ProjectIntegrationTargetRepository; -import com.kntro.reqsai.integrations.application.result.BatchPushResult; -import com.kntro.reqsai.integrations.application.result.StoryPushResult; -import com.kntro.reqsai.integrations.application.service.StoryPushService; -import com.kntro.reqsai.integrations.application.service.StoryPushService.PushContext; -import com.kntro.reqsai.integrations.domain.exception.IntegrationsExceptions; -import com.kntro.reqsai.integrations.domain.model.ProjectIntegrationTarget; +import com.kntro.reqsai.gateway.application.command.PushAllStoriesCommand; +import com.kntro.reqsai.gateway.application.port.IntegrationProvider.PushedIssue; +import com.kntro.reqsai.gateway.application.port.ProjectIntegrationTargetRepository; +import com.kntro.reqsai.gateway.application.result.BatchPushResult; +import com.kntro.reqsai.gateway.application.result.StoryPushResult; +import com.kntro.reqsai.gateway.application.service.StoryPushService; +import com.kntro.reqsai.gateway.application.service.StoryPushService.PushContext; +import com.kntro.reqsai.gateway.domain.exception.IntegrationsExceptions; +import com.kntro.reqsai.gateway.domain.model.ProjectIntegrationTarget; import com.kntro.reqsai.shared.domain.exception.DomainException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/com/kntro/reqsai/integrations/application/handler/PushStoryCommandHandler.java b/src/main/java/com/kntro/reqsai/gateway/application/handler/PushStoryCommandHandler.java similarity index 67% rename from src/main/java/com/kntro/reqsai/integrations/application/handler/PushStoryCommandHandler.java rename to src/main/java/com/kntro/reqsai/gateway/application/handler/PushStoryCommandHandler.java index cf76235c..2062890e 100644 --- a/src/main/java/com/kntro/reqsai/integrations/application/handler/PushStoryCommandHandler.java +++ b/src/main/java/com/kntro/reqsai/gateway/application/handler/PushStoryCommandHandler.java @@ -1,15 +1,15 @@ -package com.kntro.reqsai.integrations.application.handler; +package com.kntro.reqsai.gateway.application.handler; import com.kntro.reqsai.discovery.api.DiscoveryStoryReadPort; import com.kntro.reqsai.discovery.api.StoryView; -import com.kntro.reqsai.integrations.application.command.PushStoryCommand; -import com.kntro.reqsai.integrations.application.port.IntegrationProvider.PushedIssue; -import com.kntro.reqsai.integrations.application.port.ProjectIntegrationTargetRepository; -import com.kntro.reqsai.integrations.application.result.StoryPushResult; -import com.kntro.reqsai.integrations.application.service.StoryPushService; -import com.kntro.reqsai.integrations.application.service.StoryPushService.PushContext; -import com.kntro.reqsai.integrations.domain.exception.IntegrationsExceptions; -import com.kntro.reqsai.integrations.domain.model.ProjectIntegrationTarget; +import com.kntro.reqsai.gateway.application.command.PushStoryCommand; +import com.kntro.reqsai.gateway.application.port.IntegrationProvider.PushedIssue; +import com.kntro.reqsai.gateway.application.port.ProjectIntegrationTargetRepository; +import com.kntro.reqsai.gateway.application.result.StoryPushResult; +import com.kntro.reqsai.gateway.application.service.StoryPushService; +import com.kntro.reqsai.gateway.application.service.StoryPushService.PushContext; +import com.kntro.reqsai.gateway.domain.exception.IntegrationsExceptions; +import com.kntro.reqsai.gateway.domain.model.ProjectIntegrationTarget; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; diff --git a/src/main/java/com/kntro/reqsai/integrations/application/handler/SaveProjectTargetCommandHandler.java b/src/main/java/com/kntro/reqsai/gateway/application/handler/SaveProjectTargetCommandHandler.java similarity index 74% rename from src/main/java/com/kntro/reqsai/integrations/application/handler/SaveProjectTargetCommandHandler.java rename to src/main/java/com/kntro/reqsai/gateway/application/handler/SaveProjectTargetCommandHandler.java index 8e9b4270..eb2b61eb 100644 --- a/src/main/java/com/kntro/reqsai/integrations/application/handler/SaveProjectTargetCommandHandler.java +++ b/src/main/java/com/kntro/reqsai/gateway/application/handler/SaveProjectTargetCommandHandler.java @@ -1,10 +1,10 @@ -package com.kntro.reqsai.integrations.application.handler; +package com.kntro.reqsai.gateway.application.handler; -import com.kntro.reqsai.integrations.application.command.SaveProjectTargetCommand; -import com.kntro.reqsai.integrations.application.port.IntegrationConnectionRepository; -import com.kntro.reqsai.integrations.application.port.ProjectIntegrationTargetRepository; -import com.kntro.reqsai.integrations.domain.exception.IntegrationsExceptions; -import com.kntro.reqsai.integrations.domain.model.ProjectIntegrationTarget; +import com.kntro.reqsai.gateway.application.command.SaveProjectTargetCommand; +import com.kntro.reqsai.gateway.application.port.IntegrationConnectionRepository; +import com.kntro.reqsai.gateway.application.port.ProjectIntegrationTargetRepository; +import com.kntro.reqsai.gateway.domain.exception.IntegrationsExceptions; +import com.kntro.reqsai.gateway.domain.model.ProjectIntegrationTarget; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; diff --git a/src/main/java/com/kntro/reqsai/integrations/application/handler/TestConnectionQueryHandler.java b/src/main/java/com/kntro/reqsai/gateway/application/handler/TestConnectionQueryHandler.java similarity index 72% rename from src/main/java/com/kntro/reqsai/integrations/application/handler/TestConnectionQueryHandler.java rename to src/main/java/com/kntro/reqsai/gateway/application/handler/TestConnectionQueryHandler.java index 5f3f99e3..124d85be 100644 --- a/src/main/java/com/kntro/reqsai/integrations/application/handler/TestConnectionQueryHandler.java +++ b/src/main/java/com/kntro/reqsai/gateway/application/handler/TestConnectionQueryHandler.java @@ -1,13 +1,13 @@ -package com.kntro.reqsai.integrations.application.handler; +package com.kntro.reqsai.gateway.application.handler; -import com.kntro.reqsai.integrations.application.port.IntegrationConnectionRepository; -import com.kntro.reqsai.integrations.application.port.IntegrationProvider; -import com.kntro.reqsai.integrations.application.query.TestConnectionQuery; -import com.kntro.reqsai.integrations.application.result.ConnectionTestResult; -import com.kntro.reqsai.integrations.application.service.ProviderCredentialsFactory; -import com.kntro.reqsai.integrations.application.service.ProviderRegistry; -import com.kntro.reqsai.integrations.domain.exception.IntegrationsExceptions; -import com.kntro.reqsai.integrations.domain.model.IntegrationConnection; +import com.kntro.reqsai.gateway.application.port.IntegrationConnectionRepository; +import com.kntro.reqsai.gateway.application.port.IntegrationProvider; +import com.kntro.reqsai.gateway.application.query.TestConnectionQuery; +import com.kntro.reqsai.gateway.application.result.ConnectionTestResult; +import com.kntro.reqsai.gateway.application.service.ProviderCredentialsFactory; +import com.kntro.reqsai.gateway.application.service.ProviderRegistry; +import com.kntro.reqsai.gateway.domain.exception.IntegrationsExceptions; +import com.kntro.reqsai.gateway.domain.model.IntegrationConnection; import com.kntro.reqsai.shared.domain.exception.InfrastructureException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/com/kntro/reqsai/integrations/application/port/IntegrationConnectionRepository.java b/src/main/java/com/kntro/reqsai/gateway/application/port/IntegrationConnectionRepository.java similarity index 72% rename from src/main/java/com/kntro/reqsai/integrations/application/port/IntegrationConnectionRepository.java rename to src/main/java/com/kntro/reqsai/gateway/application/port/IntegrationConnectionRepository.java index cbfe0d66..070ebcdd 100644 --- a/src/main/java/com/kntro/reqsai/integrations/application/port/IntegrationConnectionRepository.java +++ b/src/main/java/com/kntro/reqsai/gateway/application/port/IntegrationConnectionRepository.java @@ -1,8 +1,8 @@ -package com.kntro.reqsai.integrations.application.port; +package com.kntro.reqsai.gateway.application.port; -import com.kntro.reqsai.integrations.domain.model.ConnectionStatus; -import com.kntro.reqsai.integrations.domain.model.IntegrationConnection; -import com.kntro.reqsai.integrations.domain.model.IntegrationProviderType; +import com.kntro.reqsai.gateway.domain.model.ConnectionStatus; +import com.kntro.reqsai.gateway.domain.model.IntegrationConnection; +import com.kntro.reqsai.gateway.domain.model.IntegrationProviderType; import java.util.List; import java.util.Optional; diff --git a/src/main/java/com/kntro/reqsai/integrations/application/port/IntegrationProvider.java b/src/main/java/com/kntro/reqsai/gateway/application/port/IntegrationProvider.java similarity index 93% rename from src/main/java/com/kntro/reqsai/integrations/application/port/IntegrationProvider.java rename to src/main/java/com/kntro/reqsai/gateway/application/port/IntegrationProvider.java index 557e7eeb..256c5ccd 100644 --- a/src/main/java/com/kntro/reqsai/integrations/application/port/IntegrationProvider.java +++ b/src/main/java/com/kntro/reqsai/gateway/application/port/IntegrationProvider.java @@ -1,7 +1,7 @@ -package com.kntro.reqsai.integrations.application.port; +package com.kntro.reqsai.gateway.application.port; import com.kntro.reqsai.discovery.api.StoryView; -import com.kntro.reqsai.integrations.domain.model.IntegrationProviderType; +import com.kntro.reqsai.gateway.domain.model.IntegrationProviderType; import java.util.List; diff --git a/src/main/java/com/kntro/reqsai/integrations/application/port/ProjectIntegrationTargetRepository.java b/src/main/java/com/kntro/reqsai/gateway/application/port/ProjectIntegrationTargetRepository.java similarity index 74% rename from src/main/java/com/kntro/reqsai/integrations/application/port/ProjectIntegrationTargetRepository.java rename to src/main/java/com/kntro/reqsai/gateway/application/port/ProjectIntegrationTargetRepository.java index 85a436de..1402c664 100644 --- a/src/main/java/com/kntro/reqsai/integrations/application/port/ProjectIntegrationTargetRepository.java +++ b/src/main/java/com/kntro/reqsai/gateway/application/port/ProjectIntegrationTargetRepository.java @@ -1,6 +1,6 @@ -package com.kntro.reqsai.integrations.application.port; +package com.kntro.reqsai.gateway.application.port; -import com.kntro.reqsai.integrations.domain.model.ProjectIntegrationTarget; +import com.kntro.reqsai.gateway.domain.model.ProjectIntegrationTarget; import java.util.Optional; import java.util.UUID; diff --git a/src/main/java/com/kntro/reqsai/integrations/application/port/SecretCipher.java b/src/main/java/com/kntro/reqsai/gateway/application/port/SecretCipher.java similarity index 94% rename from src/main/java/com/kntro/reqsai/integrations/application/port/SecretCipher.java rename to src/main/java/com/kntro/reqsai/gateway/application/port/SecretCipher.java index 04cb9b1d..808d6c36 100644 --- a/src/main/java/com/kntro/reqsai/integrations/application/port/SecretCipher.java +++ b/src/main/java/com/kntro/reqsai/gateway/application/port/SecretCipher.java @@ -1,4 +1,4 @@ -package com.kntro.reqsai.integrations.application.port; +package com.kntro.reqsai.gateway.application.port; /** * Port for symmetric encryption of integration secrets at rest (ADR-0022). diff --git a/src/main/java/com/kntro/reqsai/integrations/application/query/GetProjectTargetQuery.java b/src/main/java/com/kntro/reqsai/gateway/application/query/GetProjectTargetQuery.java similarity index 71% rename from src/main/java/com/kntro/reqsai/integrations/application/query/GetProjectTargetQuery.java rename to src/main/java/com/kntro/reqsai/gateway/application/query/GetProjectTargetQuery.java index beba29e2..dda24d1f 100644 --- a/src/main/java/com/kntro/reqsai/integrations/application/query/GetProjectTargetQuery.java +++ b/src/main/java/com/kntro/reqsai/gateway/application/query/GetProjectTargetQuery.java @@ -1,4 +1,4 @@ -package com.kntro.reqsai.integrations.application.query; +package com.kntro.reqsai.gateway.application.query; import java.util.UUID; diff --git a/src/main/java/com/kntro/reqsai/integrations/application/query/ListConnectionsQuery.java b/src/main/java/com/kntro/reqsai/gateway/application/query/ListConnectionsQuery.java similarity index 73% rename from src/main/java/com/kntro/reqsai/integrations/application/query/ListConnectionsQuery.java rename to src/main/java/com/kntro/reqsai/gateway/application/query/ListConnectionsQuery.java index b2767180..378493ee 100644 --- a/src/main/java/com/kntro/reqsai/integrations/application/query/ListConnectionsQuery.java +++ b/src/main/java/com/kntro/reqsai/gateway/application/query/ListConnectionsQuery.java @@ -1,4 +1,4 @@ -package com.kntro.reqsai.integrations.application.query; +package com.kntro.reqsai.gateway.application.query; import java.util.UUID; diff --git a/src/main/java/com/kntro/reqsai/integrations/application/query/ListJiraIssueTypesQuery.java b/src/main/java/com/kntro/reqsai/gateway/application/query/ListJiraIssueTypesQuery.java similarity index 80% rename from src/main/java/com/kntro/reqsai/integrations/application/query/ListJiraIssueTypesQuery.java rename to src/main/java/com/kntro/reqsai/gateway/application/query/ListJiraIssueTypesQuery.java index 0cb92586..fda6f8a9 100644 --- a/src/main/java/com/kntro/reqsai/integrations/application/query/ListJiraIssueTypesQuery.java +++ b/src/main/java/com/kntro/reqsai/gateway/application/query/ListJiraIssueTypesQuery.java @@ -1,4 +1,4 @@ -package com.kntro.reqsai.integrations.application.query; +package com.kntro.reqsai.gateway.application.query; import java.util.UUID; diff --git a/src/main/java/com/kntro/reqsai/integrations/application/query/ListJiraProjectsQuery.java b/src/main/java/com/kntro/reqsai/gateway/application/query/ListJiraProjectsQuery.java similarity index 77% rename from src/main/java/com/kntro/reqsai/integrations/application/query/ListJiraProjectsQuery.java rename to src/main/java/com/kntro/reqsai/gateway/application/query/ListJiraProjectsQuery.java index 9ab3549e..dbc0a1e8 100644 --- a/src/main/java/com/kntro/reqsai/integrations/application/query/ListJiraProjectsQuery.java +++ b/src/main/java/com/kntro/reqsai/gateway/application/query/ListJiraProjectsQuery.java @@ -1,4 +1,4 @@ -package com.kntro.reqsai.integrations.application.query; +package com.kntro.reqsai.gateway.application.query; import java.util.UUID; diff --git a/src/main/java/com/kntro/reqsai/integrations/application/query/TestConnectionQuery.java b/src/main/java/com/kntro/reqsai/gateway/application/query/TestConnectionQuery.java similarity index 77% rename from src/main/java/com/kntro/reqsai/integrations/application/query/TestConnectionQuery.java rename to src/main/java/com/kntro/reqsai/gateway/application/query/TestConnectionQuery.java index e0369bf4..f80ab43d 100644 --- a/src/main/java/com/kntro/reqsai/integrations/application/query/TestConnectionQuery.java +++ b/src/main/java/com/kntro/reqsai/gateway/application/query/TestConnectionQuery.java @@ -1,4 +1,4 @@ -package com.kntro.reqsai.integrations.application.query; +package com.kntro.reqsai.gateway.application.query; import java.util.UUID; diff --git a/src/main/java/com/kntro/reqsai/integrations/application/result/BatchPushResult.java b/src/main/java/com/kntro/reqsai/gateway/application/result/BatchPushResult.java similarity index 88% rename from src/main/java/com/kntro/reqsai/integrations/application/result/BatchPushResult.java rename to src/main/java/com/kntro/reqsai/gateway/application/result/BatchPushResult.java index 5408dd2b..1b1299c9 100644 --- a/src/main/java/com/kntro/reqsai/integrations/application/result/BatchPushResult.java +++ b/src/main/java/com/kntro/reqsai/gateway/application/result/BatchPushResult.java @@ -1,4 +1,4 @@ -package com.kntro.reqsai.integrations.application.result; +package com.kntro.reqsai.gateway.application.result; import java.util.List; diff --git a/src/main/java/com/kntro/reqsai/integrations/application/result/ConnectionTestResult.java b/src/main/java/com/kntro/reqsai/gateway/application/result/ConnectionTestResult.java similarity index 79% rename from src/main/java/com/kntro/reqsai/integrations/application/result/ConnectionTestResult.java rename to src/main/java/com/kntro/reqsai/gateway/application/result/ConnectionTestResult.java index c2d644e7..1e92bf1b 100644 --- a/src/main/java/com/kntro/reqsai/integrations/application/result/ConnectionTestResult.java +++ b/src/main/java/com/kntro/reqsai/gateway/application/result/ConnectionTestResult.java @@ -1,4 +1,4 @@ -package com.kntro.reqsai.integrations.application.result; +package com.kntro.reqsai.gateway.application.result; import org.jspecify.annotations.Nullable; diff --git a/src/main/java/com/kntro/reqsai/integrations/application/result/StoryPushResult.java b/src/main/java/com/kntro/reqsai/gateway/application/result/StoryPushResult.java similarity index 93% rename from src/main/java/com/kntro/reqsai/integrations/application/result/StoryPushResult.java rename to src/main/java/com/kntro/reqsai/gateway/application/result/StoryPushResult.java index 5ee2ac15..21dbd20f 100644 --- a/src/main/java/com/kntro/reqsai/integrations/application/result/StoryPushResult.java +++ b/src/main/java/com/kntro/reqsai/gateway/application/result/StoryPushResult.java @@ -1,4 +1,4 @@ -package com.kntro.reqsai.integrations.application.result; +package com.kntro.reqsai.gateway.application.result; import org.jspecify.annotations.Nullable; diff --git a/src/main/java/com/kntro/reqsai/integrations/application/service/ProviderCredentialsFactory.java b/src/main/java/com/kntro/reqsai/gateway/application/service/ProviderCredentialsFactory.java similarity index 72% rename from src/main/java/com/kntro/reqsai/integrations/application/service/ProviderCredentialsFactory.java rename to src/main/java/com/kntro/reqsai/gateway/application/service/ProviderCredentialsFactory.java index e1ae6310..6eabedb7 100644 --- a/src/main/java/com/kntro/reqsai/integrations/application/service/ProviderCredentialsFactory.java +++ b/src/main/java/com/kntro/reqsai/gateway/application/service/ProviderCredentialsFactory.java @@ -1,7 +1,7 @@ -package com.kntro.reqsai.integrations.application.service; +package com.kntro.reqsai.gateway.application.service; -import com.kntro.reqsai.integrations.application.port.IntegrationProvider.ProviderCredentials; -import com.kntro.reqsai.integrations.domain.model.IntegrationConnection; +import com.kntro.reqsai.gateway.application.port.IntegrationProvider.ProviderCredentials; +import com.kntro.reqsai.gateway.domain.model.IntegrationConnection; import org.springframework.stereotype.Component; /** diff --git a/src/main/java/com/kntro/reqsai/integrations/application/service/ProviderRegistry.java b/src/main/java/com/kntro/reqsai/gateway/application/service/ProviderRegistry.java similarity index 83% rename from src/main/java/com/kntro/reqsai/integrations/application/service/ProviderRegistry.java rename to src/main/java/com/kntro/reqsai/gateway/application/service/ProviderRegistry.java index 1c357e67..efd6cb5a 100644 --- a/src/main/java/com/kntro/reqsai/integrations/application/service/ProviderRegistry.java +++ b/src/main/java/com/kntro/reqsai/gateway/application/service/ProviderRegistry.java @@ -1,7 +1,7 @@ -package com.kntro.reqsai.integrations.application.service; +package com.kntro.reqsai.gateway.application.service; -import com.kntro.reqsai.integrations.application.port.IntegrationProvider; -import com.kntro.reqsai.integrations.domain.model.IntegrationProviderType; +import com.kntro.reqsai.gateway.application.port.IntegrationProvider; +import com.kntro.reqsai.gateway.domain.model.IntegrationProviderType; import org.springframework.stereotype.Component; import java.util.EnumMap; diff --git a/src/main/java/com/kntro/reqsai/integrations/application/service/StoryPushService.java b/src/main/java/com/kntro/reqsai/gateway/application/service/StoryPushService.java similarity index 73% rename from src/main/java/com/kntro/reqsai/integrations/application/service/StoryPushService.java rename to src/main/java/com/kntro/reqsai/gateway/application/service/StoryPushService.java index 9e91379f..86290b4a 100644 --- a/src/main/java/com/kntro/reqsai/integrations/application/service/StoryPushService.java +++ b/src/main/java/com/kntro/reqsai/gateway/application/service/StoryPushService.java @@ -1,13 +1,13 @@ -package com.kntro.reqsai.integrations.application.service; +package com.kntro.reqsai.gateway.application.service; import com.kntro.reqsai.discovery.api.StoryView; -import com.kntro.reqsai.integrations.application.port.IntegrationConnectionRepository; -import com.kntro.reqsai.integrations.application.port.IntegrationProvider; -import com.kntro.reqsai.integrations.application.port.IntegrationProvider.ProviderCredentials; -import com.kntro.reqsai.integrations.application.port.IntegrationProvider.PushedIssue; -import com.kntro.reqsai.integrations.domain.exception.IntegrationsExceptions; -import com.kntro.reqsai.integrations.domain.model.IntegrationConnection; -import com.kntro.reqsai.integrations.domain.model.ProjectIntegrationTarget; +import com.kntro.reqsai.gateway.application.port.IntegrationConnectionRepository; +import com.kntro.reqsai.gateway.application.port.IntegrationProvider; +import com.kntro.reqsai.gateway.application.port.IntegrationProvider.ProviderCredentials; +import com.kntro.reqsai.gateway.application.port.IntegrationProvider.PushedIssue; +import com.kntro.reqsai.gateway.domain.exception.IntegrationsExceptions; +import com.kntro.reqsai.gateway.domain.model.IntegrationConnection; +import com.kntro.reqsai.gateway.domain.model.ProjectIntegrationTarget; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; diff --git a/src/main/java/com/kntro/reqsai/integrations/domain/exception/IntegrationsError.java b/src/main/java/com/kntro/reqsai/gateway/domain/exception/IntegrationsError.java similarity index 83% rename from src/main/java/com/kntro/reqsai/integrations/domain/exception/IntegrationsError.java rename to src/main/java/com/kntro/reqsai/gateway/domain/exception/IntegrationsError.java index e790d91c..40510f76 100644 --- a/src/main/java/com/kntro/reqsai/integrations/domain/exception/IntegrationsError.java +++ b/src/main/java/com/kntro/reqsai/gateway/domain/exception/IntegrationsError.java @@ -1,4 +1,4 @@ -package com.kntro.reqsai.integrations.domain.exception; +package com.kntro.reqsai.gateway.domain.exception; import com.kntro.reqsai.shared.domain.exception.ErrorCatalog; import org.springframework.http.HttpStatus; @@ -6,7 +6,7 @@ /** * Domain (business-rule) error codes owned by the Integrations bounded context (ADR-0022). Mapped to * RFC 9457 {@code ProblemDetail} by the shared {@code GlobalExceptionHandler}. External-service - * failures live in {@link com.kntro.reqsai.integrations.infrastructure.exception.IntegrationsInfrastructureError}. + * failures live in {@link com.kntro.reqsai.gateway.infrastructure.exception.IntegrationsInfrastructureError}. */ public enum IntegrationsError implements ErrorCatalog { diff --git a/src/main/java/com/kntro/reqsai/integrations/domain/exception/IntegrationsExceptions.java b/src/main/java/com/kntro/reqsai/gateway/domain/exception/IntegrationsExceptions.java similarity index 97% rename from src/main/java/com/kntro/reqsai/integrations/domain/exception/IntegrationsExceptions.java rename to src/main/java/com/kntro/reqsai/gateway/domain/exception/IntegrationsExceptions.java index 7344f1d7..2376c336 100644 --- a/src/main/java/com/kntro/reqsai/integrations/domain/exception/IntegrationsExceptions.java +++ b/src/main/java/com/kntro/reqsai/gateway/domain/exception/IntegrationsExceptions.java @@ -1,4 +1,4 @@ -package com.kntro.reqsai.integrations.domain.exception; +package com.kntro.reqsai.gateway.domain.exception; import com.kntro.reqsai.shared.domain.exception.DomainException; import com.kntro.reqsai.shared.domain.exception.EntityNotFoundException; diff --git a/src/main/java/com/kntro/reqsai/integrations/domain/model/ConnectionStatus.java b/src/main/java/com/kntro/reqsai/gateway/domain/model/ConnectionStatus.java similarity index 93% rename from src/main/java/com/kntro/reqsai/integrations/domain/model/ConnectionStatus.java rename to src/main/java/com/kntro/reqsai/gateway/domain/model/ConnectionStatus.java index 7aa928ed..4db82562 100644 --- a/src/main/java/com/kntro/reqsai/integrations/domain/model/ConnectionStatus.java +++ b/src/main/java/com/kntro/reqsai/gateway/domain/model/ConnectionStatus.java @@ -1,4 +1,4 @@ -package com.kntro.reqsai.integrations.domain.model; +package com.kntro.reqsai.gateway.domain.model; /** * Lifecycle of an {@link IntegrationConnection}. A connection is {@code CONNECTED} while its stored diff --git a/src/main/java/com/kntro/reqsai/integrations/domain/model/IntegrationConnection.java b/src/main/java/com/kntro/reqsai/gateway/domain/model/IntegrationConnection.java similarity index 96% rename from src/main/java/com/kntro/reqsai/integrations/domain/model/IntegrationConnection.java rename to src/main/java/com/kntro/reqsai/gateway/domain/model/IntegrationConnection.java index db0754b6..5f953b57 100644 --- a/src/main/java/com/kntro/reqsai/integrations/domain/model/IntegrationConnection.java +++ b/src/main/java/com/kntro/reqsai/gateway/domain/model/IntegrationConnection.java @@ -1,6 +1,6 @@ -package com.kntro.reqsai.integrations.domain.model; +package com.kntro.reqsai.gateway.domain.model; -import com.kntro.reqsai.integrations.infrastructure.persistence.converters.EncryptedStringConverter; +import com.kntro.reqsai.gateway.infrastructure.persistence.converters.EncryptedStringConverter; import com.kntro.reqsai.shared.domain.model.AggregateRoot; import com.kntro.reqsai.shared.domain.support.Assert; import jakarta.persistence.Column; diff --git a/src/main/java/com/kntro/reqsai/integrations/domain/model/IntegrationProviderType.java b/src/main/java/com/kntro/reqsai/gateway/domain/model/IntegrationProviderType.java similarity index 85% rename from src/main/java/com/kntro/reqsai/integrations/domain/model/IntegrationProviderType.java rename to src/main/java/com/kntro/reqsai/gateway/domain/model/IntegrationProviderType.java index bad409c7..462056ef 100644 --- a/src/main/java/com/kntro/reqsai/integrations/domain/model/IntegrationProviderType.java +++ b/src/main/java/com/kntro/reqsai/gateway/domain/model/IntegrationProviderType.java @@ -1,4 +1,4 @@ -package com.kntro.reqsai.integrations.domain.model; +package com.kntro.reqsai.gateway.domain.model; /** * Supported third-party integration providers. Only {@code JIRA} exists today; the value is stored on diff --git a/src/main/java/com/kntro/reqsai/integrations/domain/model/ProjectIntegrationTarget.java b/src/main/java/com/kntro/reqsai/gateway/domain/model/ProjectIntegrationTarget.java similarity index 97% rename from src/main/java/com/kntro/reqsai/integrations/domain/model/ProjectIntegrationTarget.java rename to src/main/java/com/kntro/reqsai/gateway/domain/model/ProjectIntegrationTarget.java index 5465b05d..841f13cf 100644 --- a/src/main/java/com/kntro/reqsai/integrations/domain/model/ProjectIntegrationTarget.java +++ b/src/main/java/com/kntro/reqsai/gateway/domain/model/ProjectIntegrationTarget.java @@ -1,4 +1,4 @@ -package com.kntro.reqsai.integrations.domain.model; +package com.kntro.reqsai.gateway.domain.model; import com.kntro.reqsai.shared.domain.model.AggregateRoot; import com.kntro.reqsai.shared.domain.support.Assert; diff --git a/src/main/java/com/kntro/reqsai/integrations/infrastructure/crypto/AesGcmCipher.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/crypto/AesGcmCipher.java similarity index 93% rename from src/main/java/com/kntro/reqsai/integrations/infrastructure/crypto/AesGcmCipher.java rename to src/main/java/com/kntro/reqsai/gateway/infrastructure/crypto/AesGcmCipher.java index 86c94c08..3e982ab2 100644 --- a/src/main/java/com/kntro/reqsai/integrations/infrastructure/crypto/AesGcmCipher.java +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/crypto/AesGcmCipher.java @@ -1,7 +1,7 @@ -package com.kntro.reqsai.integrations.infrastructure.crypto; +package com.kntro.reqsai.gateway.infrastructure.crypto; -import com.kntro.reqsai.integrations.application.port.SecretCipher; -import com.kntro.reqsai.integrations.infrastructure.exception.IntegrationsInfrastructureExceptions; +import com.kntro.reqsai.gateway.application.port.SecretCipher; +import com.kntro.reqsai.gateway.infrastructure.exception.IntegrationsInfrastructureExceptions; import javax.crypto.Cipher; import javax.crypto.spec.GCMParameterSpec; diff --git a/src/main/java/com/kntro/reqsai/integrations/infrastructure/crypto/IntegrationsCryptoConfiguration.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/crypto/IntegrationsCryptoConfiguration.java similarity index 81% rename from src/main/java/com/kntro/reqsai/integrations/infrastructure/crypto/IntegrationsCryptoConfiguration.java rename to src/main/java/com/kntro/reqsai/gateway/infrastructure/crypto/IntegrationsCryptoConfiguration.java index fcd98887..0b4c7167 100644 --- a/src/main/java/com/kntro/reqsai/integrations/infrastructure/crypto/IntegrationsCryptoConfiguration.java +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/crypto/IntegrationsCryptoConfiguration.java @@ -1,8 +1,8 @@ -package com.kntro.reqsai.integrations.infrastructure.crypto; +package com.kntro.reqsai.gateway.infrastructure.crypto; -import com.kntro.reqsai.integrations.application.port.SecretCipher; -import com.kntro.reqsai.integrations.infrastructure.exception.IntegrationsInfrastructureExceptions; -import com.kntro.reqsai.integrations.infrastructure.persistence.converters.EncryptedStringConverter; +import com.kntro.reqsai.gateway.application.port.SecretCipher; +import com.kntro.reqsai.gateway.infrastructure.exception.IntegrationsInfrastructureExceptions; +import com.kntro.reqsai.gateway.infrastructure.persistence.converters.EncryptedStringConverter; import jakarta.annotation.PostConstruct; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; diff --git a/src/main/java/com/kntro/reqsai/integrations/infrastructure/exception/IntegrationsInfrastructureError.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/exception/IntegrationsInfrastructureError.java similarity index 86% rename from src/main/java/com/kntro/reqsai/integrations/infrastructure/exception/IntegrationsInfrastructureError.java rename to src/main/java/com/kntro/reqsai/gateway/infrastructure/exception/IntegrationsInfrastructureError.java index daeda82e..69930f15 100644 --- a/src/main/java/com/kntro/reqsai/integrations/infrastructure/exception/IntegrationsInfrastructureError.java +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/exception/IntegrationsInfrastructureError.java @@ -1,4 +1,4 @@ -package com.kntro.reqsai.integrations.infrastructure.exception; +package com.kntro.reqsai.gateway.infrastructure.exception; import com.kntro.reqsai.shared.domain.exception.ErrorCatalog; import org.springframework.http.HttpStatus; @@ -6,7 +6,7 @@ /** * Error codes for external-service and crypto failures in the Integrations bounded context (ADR-0022). * These are infrastructure concerns (Jira reachability/auth, encryption) and must NOT live in - * {@link com.kntro.reqsai.integrations.domain.exception.IntegrationsError}. + * {@link com.kntro.reqsai.gateway.domain.exception.IntegrationsError}. */ public enum IntegrationsInfrastructureError implements ErrorCatalog { diff --git a/src/main/java/com/kntro/reqsai/integrations/infrastructure/exception/IntegrationsInfrastructureExceptions.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/exception/IntegrationsInfrastructureExceptions.java similarity index 90% rename from src/main/java/com/kntro/reqsai/integrations/infrastructure/exception/IntegrationsInfrastructureExceptions.java rename to src/main/java/com/kntro/reqsai/gateway/infrastructure/exception/IntegrationsInfrastructureExceptions.java index 8526de03..d1b57f92 100644 --- a/src/main/java/com/kntro/reqsai/integrations/infrastructure/exception/IntegrationsInfrastructureExceptions.java +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/exception/IntegrationsInfrastructureExceptions.java @@ -1,10 +1,10 @@ -package com.kntro.reqsai.integrations.infrastructure.exception; +package com.kntro.reqsai.gateway.infrastructure.exception; import com.kntro.reqsai.shared.domain.exception.InfrastructureException; /** * Factory for Integrations infrastructure exceptions — the infrastructure counterpart of - * {@link com.kntro.reqsai.integrations.domain.exception.IntegrationsExceptions}. Adapters use this + * {@link com.kntro.reqsai.gateway.domain.exception.IntegrationsExceptions}. Adapters use this * factory instead of constructing {@link InfrastructureException} inline. Messages never include the * Jira token. */ diff --git a/src/main/java/com/kntro/reqsai/integrations/infrastructure/jira/JiraAdfBuilder.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraAdfBuilder.java similarity index 97% rename from src/main/java/com/kntro/reqsai/integrations/infrastructure/jira/JiraAdfBuilder.java rename to src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraAdfBuilder.java index 240e04f0..492ee7ee 100644 --- a/src/main/java/com/kntro/reqsai/integrations/infrastructure/jira/JiraAdfBuilder.java +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraAdfBuilder.java @@ -1,4 +1,4 @@ -package com.kntro.reqsai.integrations.infrastructure.jira; +package com.kntro.reqsai.gateway.infrastructure.jira; import com.kntro.reqsai.discovery.api.AcceptanceCriterionView; import com.kntro.reqsai.discovery.api.StoryView; diff --git a/src/main/java/com/kntro/reqsai/integrations/infrastructure/jira/JiraClient.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraClient.java similarity index 97% rename from src/main/java/com/kntro/reqsai/integrations/infrastructure/jira/JiraClient.java rename to src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraClient.java index 25f31caf..d9d07ce1 100644 --- a/src/main/java/com/kntro/reqsai/integrations/infrastructure/jira/JiraClient.java +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraClient.java @@ -1,7 +1,7 @@ -package com.kntro.reqsai.integrations.infrastructure.jira; +package com.kntro.reqsai.gateway.infrastructure.jira; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.kntro.reqsai.integrations.infrastructure.exception.IntegrationsInfrastructureExceptions; +import com.kntro.reqsai.gateway.infrastructure.exception.IntegrationsInfrastructureExceptions; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatusCode; import org.springframework.http.MediaType; diff --git a/src/main/java/com/kntro/reqsai/integrations/infrastructure/jira/JiraProvider.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraProvider.java similarity index 89% rename from src/main/java/com/kntro/reqsai/integrations/infrastructure/jira/JiraProvider.java rename to src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraProvider.java index 7516396e..96ab8085 100644 --- a/src/main/java/com/kntro/reqsai/integrations/infrastructure/jira/JiraProvider.java +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraProvider.java @@ -1,8 +1,8 @@ -package com.kntro.reqsai.integrations.infrastructure.jira; +package com.kntro.reqsai.gateway.infrastructure.jira; import com.kntro.reqsai.discovery.api.StoryView; -import com.kntro.reqsai.integrations.application.port.IntegrationProvider; -import com.kntro.reqsai.integrations.domain.model.IntegrationProviderType; +import com.kntro.reqsai.gateway.application.port.IntegrationProvider; +import com.kntro.reqsai.gateway.domain.model.IntegrationProviderType; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; diff --git a/src/main/java/com/kntro/reqsai/integrations/infrastructure/persistence/adapters/IntegrationConnectionRepositoryAdapter.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/persistence/adapters/IntegrationConnectionRepositoryAdapter.java similarity index 74% rename from src/main/java/com/kntro/reqsai/integrations/infrastructure/persistence/adapters/IntegrationConnectionRepositoryAdapter.java rename to src/main/java/com/kntro/reqsai/gateway/infrastructure/persistence/adapters/IntegrationConnectionRepositoryAdapter.java index efb25506..dd2c1a9b 100644 --- a/src/main/java/com/kntro/reqsai/integrations/infrastructure/persistence/adapters/IntegrationConnectionRepositoryAdapter.java +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/persistence/adapters/IntegrationConnectionRepositoryAdapter.java @@ -1,10 +1,10 @@ -package com.kntro.reqsai.integrations.infrastructure.persistence.adapters; +package com.kntro.reqsai.gateway.infrastructure.persistence.adapters; -import com.kntro.reqsai.integrations.application.port.IntegrationConnectionRepository; -import com.kntro.reqsai.integrations.domain.model.ConnectionStatus; -import com.kntro.reqsai.integrations.domain.model.IntegrationConnection; -import com.kntro.reqsai.integrations.domain.model.IntegrationProviderType; -import com.kntro.reqsai.integrations.infrastructure.persistence.repositories.IntegrationConnectionJpaRepository; +import com.kntro.reqsai.gateway.application.port.IntegrationConnectionRepository; +import com.kntro.reqsai.gateway.domain.model.ConnectionStatus; +import com.kntro.reqsai.gateway.domain.model.IntegrationConnection; +import com.kntro.reqsai.gateway.domain.model.IntegrationProviderType; +import com.kntro.reqsai.gateway.infrastructure.persistence.repositories.IntegrationConnectionJpaRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; diff --git a/src/main/java/com/kntro/reqsai/integrations/infrastructure/persistence/adapters/ProjectIntegrationTargetRepositoryAdapter.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/persistence/adapters/ProjectIntegrationTargetRepositoryAdapter.java similarity index 69% rename from src/main/java/com/kntro/reqsai/integrations/infrastructure/persistence/adapters/ProjectIntegrationTargetRepositoryAdapter.java rename to src/main/java/com/kntro/reqsai/gateway/infrastructure/persistence/adapters/ProjectIntegrationTargetRepositoryAdapter.java index ecf13607..f6e7f827 100644 --- a/src/main/java/com/kntro/reqsai/integrations/infrastructure/persistence/adapters/ProjectIntegrationTargetRepositoryAdapter.java +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/persistence/adapters/ProjectIntegrationTargetRepositoryAdapter.java @@ -1,8 +1,8 @@ -package com.kntro.reqsai.integrations.infrastructure.persistence.adapters; +package com.kntro.reqsai.gateway.infrastructure.persistence.adapters; -import com.kntro.reqsai.integrations.application.port.ProjectIntegrationTargetRepository; -import com.kntro.reqsai.integrations.domain.model.ProjectIntegrationTarget; -import com.kntro.reqsai.integrations.infrastructure.persistence.repositories.ProjectIntegrationTargetJpaRepository; +import com.kntro.reqsai.gateway.application.port.ProjectIntegrationTargetRepository; +import com.kntro.reqsai.gateway.domain.model.ProjectIntegrationTarget; +import com.kntro.reqsai.gateway.infrastructure.persistence.repositories.ProjectIntegrationTargetJpaRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; diff --git a/src/main/java/com/kntro/reqsai/integrations/infrastructure/persistence/converters/EncryptedStringConverter.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/persistence/converters/EncryptedStringConverter.java similarity index 88% rename from src/main/java/com/kntro/reqsai/integrations/infrastructure/persistence/converters/EncryptedStringConverter.java rename to src/main/java/com/kntro/reqsai/gateway/infrastructure/persistence/converters/EncryptedStringConverter.java index 587b8030..42d8f244 100644 --- a/src/main/java/com/kntro/reqsai/integrations/infrastructure/persistence/converters/EncryptedStringConverter.java +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/persistence/converters/EncryptedStringConverter.java @@ -1,7 +1,7 @@ -package com.kntro.reqsai.integrations.infrastructure.persistence.converters; +package com.kntro.reqsai.gateway.infrastructure.persistence.converters; -import com.kntro.reqsai.integrations.application.port.SecretCipher; -import com.kntro.reqsai.integrations.infrastructure.exception.IntegrationsInfrastructureExceptions; +import com.kntro.reqsai.gateway.application.port.SecretCipher; +import com.kntro.reqsai.gateway.infrastructure.exception.IntegrationsInfrastructureExceptions; import jakarta.persistence.AttributeConverter; import jakarta.persistence.Converter; import org.jspecify.annotations.Nullable; diff --git a/src/main/java/com/kntro/reqsai/integrations/infrastructure/persistence/repositories/IntegrationConnectionJpaRepository.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/persistence/repositories/IntegrationConnectionJpaRepository.java similarity index 66% rename from src/main/java/com/kntro/reqsai/integrations/infrastructure/persistence/repositories/IntegrationConnectionJpaRepository.java rename to src/main/java/com/kntro/reqsai/gateway/infrastructure/persistence/repositories/IntegrationConnectionJpaRepository.java index 6b6dcb9c..adb18495 100644 --- a/src/main/java/com/kntro/reqsai/integrations/infrastructure/persistence/repositories/IntegrationConnectionJpaRepository.java +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/persistence/repositories/IntegrationConnectionJpaRepository.java @@ -1,8 +1,8 @@ -package com.kntro.reqsai.integrations.infrastructure.persistence.repositories; +package com.kntro.reqsai.gateway.infrastructure.persistence.repositories; -import com.kntro.reqsai.integrations.domain.model.ConnectionStatus; -import com.kntro.reqsai.integrations.domain.model.IntegrationConnection; -import com.kntro.reqsai.integrations.domain.model.IntegrationProviderType; +import com.kntro.reqsai.gateway.domain.model.ConnectionStatus; +import com.kntro.reqsai.gateway.domain.model.IntegrationConnection; +import com.kntro.reqsai.gateway.domain.model.IntegrationProviderType; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; diff --git a/src/main/java/com/kntro/reqsai/integrations/infrastructure/persistence/repositories/ProjectIntegrationTargetJpaRepository.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/persistence/repositories/ProjectIntegrationTargetJpaRepository.java similarity index 66% rename from src/main/java/com/kntro/reqsai/integrations/infrastructure/persistence/repositories/ProjectIntegrationTargetJpaRepository.java rename to src/main/java/com/kntro/reqsai/gateway/infrastructure/persistence/repositories/ProjectIntegrationTargetJpaRepository.java index aa176d31..79dbecc3 100644 --- a/src/main/java/com/kntro/reqsai/integrations/infrastructure/persistence/repositories/ProjectIntegrationTargetJpaRepository.java +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/persistence/repositories/ProjectIntegrationTargetJpaRepository.java @@ -1,6 +1,6 @@ -package com.kntro.reqsai.integrations.infrastructure.persistence.repositories; +package com.kntro.reqsai.gateway.infrastructure.persistence.repositories; -import com.kntro.reqsai.integrations.domain.model.ProjectIntegrationTarget; +import com.kntro.reqsai.gateway.domain.model.ProjectIntegrationTarget; import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; diff --git a/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/controllers/OrganizationIntegrationControllerImpl.java b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/controllers/OrganizationIntegrationControllerImpl.java similarity index 71% rename from src/main/java/com/kntro/reqsai/integrations/interfaces/rest/controllers/OrganizationIntegrationControllerImpl.java rename to src/main/java/com/kntro/reqsai/gateway/interfaces/rest/controllers/OrganizationIntegrationControllerImpl.java index d0f8e622..e98505d5 100644 --- a/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/controllers/OrganizationIntegrationControllerImpl.java +++ b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/controllers/OrganizationIntegrationControllerImpl.java @@ -1,25 +1,25 @@ -package com.kntro.reqsai.integrations.interfaces.rest.controllers; +package com.kntro.reqsai.gateway.interfaces.rest.controllers; -import com.kntro.reqsai.integrations.application.handler.ConnectJiraCommandHandler; -import com.kntro.reqsai.integrations.application.handler.DeleteConnectionCommandHandler; -import com.kntro.reqsai.integrations.application.handler.ListConnectionsQueryHandler; -import com.kntro.reqsai.integrations.application.handler.ListJiraIssueTypesQueryHandler; -import com.kntro.reqsai.integrations.application.handler.ListJiraProjectsQueryHandler; -import com.kntro.reqsai.integrations.application.handler.TestConnectionQueryHandler; -import com.kntro.reqsai.integrations.application.command.DeleteConnectionCommand; -import com.kntro.reqsai.integrations.application.query.ListConnectionsQuery; -import com.kntro.reqsai.integrations.application.query.ListJiraIssueTypesQuery; -import com.kntro.reqsai.integrations.application.query.ListJiraProjectsQuery; -import com.kntro.reqsai.integrations.application.query.TestConnectionQuery; -import com.kntro.reqsai.integrations.domain.model.IntegrationConnection; -import com.kntro.reqsai.integrations.interfaces.rest.dto.request.ConnectJiraRequest; -import com.kntro.reqsai.integrations.interfaces.rest.dto.response.ConnectionTestResponse; -import com.kntro.reqsai.integrations.interfaces.rest.dto.response.IntegrationConnectionResponse; -import com.kntro.reqsai.integrations.interfaces.rest.dto.response.JiraIssueTypeResponse; -import com.kntro.reqsai.integrations.interfaces.rest.dto.response.JiraProjectResponse; -import com.kntro.reqsai.integrations.interfaces.rest.mappers.request.IntegrationRequestMapper; -import com.kntro.reqsai.integrations.interfaces.rest.mappers.response.IntegrationResponseMapper; -import com.kntro.reqsai.integrations.interfaces.rest.swagger.OrganizationIntegrationController; +import com.kntro.reqsai.gateway.application.handler.ConnectJiraCommandHandler; +import com.kntro.reqsai.gateway.application.handler.DeleteConnectionCommandHandler; +import com.kntro.reqsai.gateway.application.handler.ListConnectionsQueryHandler; +import com.kntro.reqsai.gateway.application.handler.ListJiraIssueTypesQueryHandler; +import com.kntro.reqsai.gateway.application.handler.ListJiraProjectsQueryHandler; +import com.kntro.reqsai.gateway.application.handler.TestConnectionQueryHandler; +import com.kntro.reqsai.gateway.application.command.DeleteConnectionCommand; +import com.kntro.reqsai.gateway.application.query.ListConnectionsQuery; +import com.kntro.reqsai.gateway.application.query.ListJiraIssueTypesQuery; +import com.kntro.reqsai.gateway.application.query.ListJiraProjectsQuery; +import com.kntro.reqsai.gateway.application.query.TestConnectionQuery; +import com.kntro.reqsai.gateway.domain.model.IntegrationConnection; +import com.kntro.reqsai.gateway.interfaces.rest.dto.request.ConnectJiraRequest; +import com.kntro.reqsai.gateway.interfaces.rest.dto.response.ConnectionTestResponse; +import com.kntro.reqsai.gateway.interfaces.rest.dto.response.IntegrationConnectionResponse; +import com.kntro.reqsai.gateway.interfaces.rest.dto.response.JiraIssueTypeResponse; +import com.kntro.reqsai.gateway.interfaces.rest.dto.response.JiraProjectResponse; +import com.kntro.reqsai.gateway.interfaces.rest.mappers.request.IntegrationRequestMapper; +import com.kntro.reqsai.gateway.interfaces.rest.mappers.response.IntegrationResponseMapper; +import com.kntro.reqsai.gateway.interfaces.rest.swagger.OrganizationIntegrationController; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; diff --git a/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/controllers/ProjectIntegrationControllerImpl.java b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/controllers/ProjectIntegrationControllerImpl.java similarity index 69% rename from src/main/java/com/kntro/reqsai/integrations/interfaces/rest/controllers/ProjectIntegrationControllerImpl.java rename to src/main/java/com/kntro/reqsai/gateway/interfaces/rest/controllers/ProjectIntegrationControllerImpl.java index 94daf4b7..1e217db3 100644 --- a/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/controllers/ProjectIntegrationControllerImpl.java +++ b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/controllers/ProjectIntegrationControllerImpl.java @@ -1,21 +1,21 @@ -package com.kntro.reqsai.integrations.interfaces.rest.controllers; +package com.kntro.reqsai.gateway.interfaces.rest.controllers; -import com.kntro.reqsai.integrations.application.command.DeleteProjectTargetCommand; -import com.kntro.reqsai.integrations.application.command.PushAllStoriesCommand; -import com.kntro.reqsai.integrations.application.command.PushStoryCommand; -import com.kntro.reqsai.integrations.application.handler.DeleteProjectTargetCommandHandler; -import com.kntro.reqsai.integrations.application.handler.GetProjectTargetQueryHandler; -import com.kntro.reqsai.integrations.application.handler.PushAllStoriesCommandHandler; -import com.kntro.reqsai.integrations.application.handler.PushStoryCommandHandler; -import com.kntro.reqsai.integrations.application.handler.SaveProjectTargetCommandHandler; -import com.kntro.reqsai.integrations.application.query.GetProjectTargetQuery; -import com.kntro.reqsai.integrations.interfaces.rest.dto.request.SaveProjectTargetRequest; -import com.kntro.reqsai.integrations.interfaces.rest.dto.response.BatchPushResponse; -import com.kntro.reqsai.integrations.interfaces.rest.dto.response.JiraPushResultResponse; -import com.kntro.reqsai.integrations.interfaces.rest.dto.response.ProjectJiraTargetResponse; -import com.kntro.reqsai.integrations.interfaces.rest.mappers.request.IntegrationRequestMapper; -import com.kntro.reqsai.integrations.interfaces.rest.mappers.response.IntegrationResponseMapper; -import com.kntro.reqsai.integrations.interfaces.rest.swagger.ProjectIntegrationController; +import com.kntro.reqsai.gateway.application.command.DeleteProjectTargetCommand; +import com.kntro.reqsai.gateway.application.command.PushAllStoriesCommand; +import com.kntro.reqsai.gateway.application.command.PushStoryCommand; +import com.kntro.reqsai.gateway.application.handler.DeleteProjectTargetCommandHandler; +import com.kntro.reqsai.gateway.application.handler.GetProjectTargetQueryHandler; +import com.kntro.reqsai.gateway.application.handler.PushAllStoriesCommandHandler; +import com.kntro.reqsai.gateway.application.handler.PushStoryCommandHandler; +import com.kntro.reqsai.gateway.application.handler.SaveProjectTargetCommandHandler; +import com.kntro.reqsai.gateway.application.query.GetProjectTargetQuery; +import com.kntro.reqsai.gateway.interfaces.rest.dto.request.SaveProjectTargetRequest; +import com.kntro.reqsai.gateway.interfaces.rest.dto.response.BatchPushResponse; +import com.kntro.reqsai.gateway.interfaces.rest.dto.response.JiraPushResultResponse; +import com.kntro.reqsai.gateway.interfaces.rest.dto.response.ProjectJiraTargetResponse; +import com.kntro.reqsai.gateway.interfaces.rest.mappers.request.IntegrationRequestMapper; +import com.kntro.reqsai.gateway.interfaces.rest.mappers.response.IntegrationResponseMapper; +import com.kntro.reqsai.gateway.interfaces.rest.swagger.ProjectIntegrationController; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; diff --git a/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/dto/request/ConnectJiraRequest.java b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/request/ConnectJiraRequest.java similarity index 92% rename from src/main/java/com/kntro/reqsai/integrations/interfaces/rest/dto/request/ConnectJiraRequest.java rename to src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/request/ConnectJiraRequest.java index 098a212c..ed5b4836 100644 --- a/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/dto/request/ConnectJiraRequest.java +++ b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/request/ConnectJiraRequest.java @@ -1,4 +1,4 @@ -package com.kntro.reqsai.integrations.interfaces.rest.dto.request; +package com.kntro.reqsai.gateway.interfaces.rest.dto.request; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Email; diff --git a/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/dto/request/SaveProjectTargetRequest.java b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/request/SaveProjectTargetRequest.java similarity index 91% rename from src/main/java/com/kntro/reqsai/integrations/interfaces/rest/dto/request/SaveProjectTargetRequest.java rename to src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/request/SaveProjectTargetRequest.java index 257e55cb..d2069709 100644 --- a/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/dto/request/SaveProjectTargetRequest.java +++ b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/request/SaveProjectTargetRequest.java @@ -1,4 +1,4 @@ -package com.kntro.reqsai.integrations.interfaces.rest.dto.request; +package com.kntro.reqsai.gateway.interfaces.rest.dto.request; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; diff --git a/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/dto/response/BatchPushResponse.java b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/BatchPushResponse.java similarity index 82% rename from src/main/java/com/kntro/reqsai/integrations/interfaces/rest/dto/response/BatchPushResponse.java rename to src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/BatchPushResponse.java index 24e7d267..464f5b51 100644 --- a/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/dto/response/BatchPushResponse.java +++ b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/BatchPushResponse.java @@ -1,4 +1,4 @@ -package com.kntro.reqsai.integrations.interfaces.rest.dto.response; +package com.kntro.reqsai.gateway.interfaces.rest.dto.response; import io.swagger.v3.oas.annotations.media.Schema; diff --git a/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/dto/response/ConnectionTestResponse.java b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/ConnectionTestResponse.java similarity index 82% rename from src/main/java/com/kntro/reqsai/integrations/interfaces/rest/dto/response/ConnectionTestResponse.java rename to src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/ConnectionTestResponse.java index c87021ca..52f29b61 100644 --- a/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/dto/response/ConnectionTestResponse.java +++ b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/ConnectionTestResponse.java @@ -1,4 +1,4 @@ -package com.kntro.reqsai.integrations.interfaces.rest.dto.response; +package com.kntro.reqsai.gateway.interfaces.rest.dto.response; import io.swagger.v3.oas.annotations.media.Schema; import org.jspecify.annotations.Nullable; diff --git a/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/dto/response/IntegrationConnectionResponse.java b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/IntegrationConnectionResponse.java similarity index 89% rename from src/main/java/com/kntro/reqsai/integrations/interfaces/rest/dto/response/IntegrationConnectionResponse.java rename to src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/IntegrationConnectionResponse.java index d9d126df..41c45c4c 100644 --- a/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/dto/response/IntegrationConnectionResponse.java +++ b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/IntegrationConnectionResponse.java @@ -1,4 +1,4 @@ -package com.kntro.reqsai.integrations.interfaces.rest.dto.response; +package com.kntro.reqsai.gateway.interfaces.rest.dto.response; import io.swagger.v3.oas.annotations.media.Schema; import org.jspecify.annotations.Nullable; diff --git a/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/dto/response/JiraIssueTypeResponse.java b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/JiraIssueTypeResponse.java similarity index 76% rename from src/main/java/com/kntro/reqsai/integrations/interfaces/rest/dto/response/JiraIssueTypeResponse.java rename to src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/JiraIssueTypeResponse.java index 60d47bf1..1bf347e7 100644 --- a/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/dto/response/JiraIssueTypeResponse.java +++ b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/JiraIssueTypeResponse.java @@ -1,4 +1,4 @@ -package com.kntro.reqsai.integrations.interfaces.rest.dto.response; +package com.kntro.reqsai.gateway.interfaces.rest.dto.response; import io.swagger.v3.oas.annotations.media.Schema; diff --git a/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/dto/response/JiraProjectResponse.java b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/JiraProjectResponse.java similarity index 75% rename from src/main/java/com/kntro/reqsai/integrations/interfaces/rest/dto/response/JiraProjectResponse.java rename to src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/JiraProjectResponse.java index 37a7e6bf..18190f4a 100644 --- a/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/dto/response/JiraProjectResponse.java +++ b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/JiraProjectResponse.java @@ -1,4 +1,4 @@ -package com.kntro.reqsai.integrations.interfaces.rest.dto.response; +package com.kntro.reqsai.gateway.interfaces.rest.dto.response; import io.swagger.v3.oas.annotations.media.Schema; diff --git a/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/dto/response/JiraPushResultResponse.java b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/JiraPushResultResponse.java similarity index 87% rename from src/main/java/com/kntro/reqsai/integrations/interfaces/rest/dto/response/JiraPushResultResponse.java rename to src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/JiraPushResultResponse.java index 4e590191..d2f96399 100644 --- a/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/dto/response/JiraPushResultResponse.java +++ b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/JiraPushResultResponse.java @@ -1,4 +1,4 @@ -package com.kntro.reqsai.integrations.interfaces.rest.dto.response; +package com.kntro.reqsai.gateway.interfaces.rest.dto.response; import io.swagger.v3.oas.annotations.media.Schema; import org.jspecify.annotations.Nullable; diff --git a/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/dto/response/ProjectJiraTargetResponse.java b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/ProjectJiraTargetResponse.java similarity index 86% rename from src/main/java/com/kntro/reqsai/integrations/interfaces/rest/dto/response/ProjectJiraTargetResponse.java rename to src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/ProjectJiraTargetResponse.java index 5e432294..ef31ca4e 100644 --- a/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/dto/response/ProjectJiraTargetResponse.java +++ b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/ProjectJiraTargetResponse.java @@ -1,4 +1,4 @@ -package com.kntro.reqsai.integrations.interfaces.rest.dto.response; +package com.kntro.reqsai.gateway.interfaces.rest.dto.response; import io.swagger.v3.oas.annotations.media.Schema; diff --git a/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/mappers/request/IntegrationRequestMapper.java b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/mappers/request/IntegrationRequestMapper.java similarity index 66% rename from src/main/java/com/kntro/reqsai/integrations/interfaces/rest/mappers/request/IntegrationRequestMapper.java rename to src/main/java/com/kntro/reqsai/gateway/interfaces/rest/mappers/request/IntegrationRequestMapper.java index 7ec3e4cb..4ae96482 100644 --- a/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/mappers/request/IntegrationRequestMapper.java +++ b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/mappers/request/IntegrationRequestMapper.java @@ -1,9 +1,9 @@ -package com.kntro.reqsai.integrations.interfaces.rest.mappers.request; +package com.kntro.reqsai.gateway.interfaces.rest.mappers.request; -import com.kntro.reqsai.integrations.application.command.ConnectJiraCommand; -import com.kntro.reqsai.integrations.application.command.SaveProjectTargetCommand; -import com.kntro.reqsai.integrations.interfaces.rest.dto.request.ConnectJiraRequest; -import com.kntro.reqsai.integrations.interfaces.rest.dto.request.SaveProjectTargetRequest; +import com.kntro.reqsai.gateway.application.command.ConnectJiraCommand; +import com.kntro.reqsai.gateway.application.command.SaveProjectTargetCommand; +import com.kntro.reqsai.gateway.interfaces.rest.dto.request.ConnectJiraRequest; +import com.kntro.reqsai.gateway.interfaces.rest.dto.request.SaveProjectTargetRequest; import java.util.UUID; diff --git a/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/mappers/response/IntegrationResponseMapper.java b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/mappers/response/IntegrationResponseMapper.java similarity index 61% rename from src/main/java/com/kntro/reqsai/integrations/interfaces/rest/mappers/response/IntegrationResponseMapper.java rename to src/main/java/com/kntro/reqsai/gateway/interfaces/rest/mappers/response/IntegrationResponseMapper.java index 8b2395cf..b05e4309 100644 --- a/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/mappers/response/IntegrationResponseMapper.java +++ b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/mappers/response/IntegrationResponseMapper.java @@ -1,19 +1,19 @@ -package com.kntro.reqsai.integrations.interfaces.rest.mappers.response; +package com.kntro.reqsai.gateway.interfaces.rest.mappers.response; -import com.kntro.reqsai.integrations.application.port.IntegrationProvider.RemoteIssueType; -import com.kntro.reqsai.integrations.application.port.IntegrationProvider.RemoteProject; -import com.kntro.reqsai.integrations.application.result.BatchPushResult; -import com.kntro.reqsai.integrations.application.result.ConnectionTestResult; -import com.kntro.reqsai.integrations.application.result.StoryPushResult; -import com.kntro.reqsai.integrations.domain.model.IntegrationConnection; -import com.kntro.reqsai.integrations.domain.model.ProjectIntegrationTarget; -import com.kntro.reqsai.integrations.interfaces.rest.dto.response.BatchPushResponse; -import com.kntro.reqsai.integrations.interfaces.rest.dto.response.ConnectionTestResponse; -import com.kntro.reqsai.integrations.interfaces.rest.dto.response.IntegrationConnectionResponse; -import com.kntro.reqsai.integrations.interfaces.rest.dto.response.JiraIssueTypeResponse; -import com.kntro.reqsai.integrations.interfaces.rest.dto.response.JiraProjectResponse; -import com.kntro.reqsai.integrations.interfaces.rest.dto.response.JiraPushResultResponse; -import com.kntro.reqsai.integrations.interfaces.rest.dto.response.ProjectJiraTargetResponse; +import com.kntro.reqsai.gateway.application.port.IntegrationProvider.RemoteIssueType; +import com.kntro.reqsai.gateway.application.port.IntegrationProvider.RemoteProject; +import com.kntro.reqsai.gateway.application.result.BatchPushResult; +import com.kntro.reqsai.gateway.application.result.ConnectionTestResult; +import com.kntro.reqsai.gateway.application.result.StoryPushResult; +import com.kntro.reqsai.gateway.domain.model.IntegrationConnection; +import com.kntro.reqsai.gateway.domain.model.ProjectIntegrationTarget; +import com.kntro.reqsai.gateway.interfaces.rest.dto.response.BatchPushResponse; +import com.kntro.reqsai.gateway.interfaces.rest.dto.response.ConnectionTestResponse; +import com.kntro.reqsai.gateway.interfaces.rest.dto.response.IntegrationConnectionResponse; +import com.kntro.reqsai.gateway.interfaces.rest.dto.response.JiraIssueTypeResponse; +import com.kntro.reqsai.gateway.interfaces.rest.dto.response.JiraProjectResponse; +import com.kntro.reqsai.gateway.interfaces.rest.dto.response.JiraPushResultResponse; +import com.kntro.reqsai.gateway.interfaces.rest.dto.response.ProjectJiraTargetResponse; /** Maps integration domain/results to REST responses. Never emits the API token. */ public final class IntegrationResponseMapper { diff --git a/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/swagger/OrganizationIntegrationController.java b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/swagger/OrganizationIntegrationController.java similarity index 93% rename from src/main/java/com/kntro/reqsai/integrations/interfaces/rest/swagger/OrganizationIntegrationController.java rename to src/main/java/com/kntro/reqsai/gateway/interfaces/rest/swagger/OrganizationIntegrationController.java index 033e843f..e266120a 100644 --- a/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/swagger/OrganizationIntegrationController.java +++ b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/swagger/OrganizationIntegrationController.java @@ -1,10 +1,10 @@ -package com.kntro.reqsai.integrations.interfaces.rest.swagger; +package com.kntro.reqsai.gateway.interfaces.rest.swagger; -import com.kntro.reqsai.integrations.interfaces.rest.dto.request.ConnectJiraRequest; -import com.kntro.reqsai.integrations.interfaces.rest.dto.response.ConnectionTestResponse; -import com.kntro.reqsai.integrations.interfaces.rest.dto.response.IntegrationConnectionResponse; -import com.kntro.reqsai.integrations.interfaces.rest.dto.response.JiraIssueTypeResponse; -import com.kntro.reqsai.integrations.interfaces.rest.dto.response.JiraProjectResponse; +import com.kntro.reqsai.gateway.interfaces.rest.dto.request.ConnectJiraRequest; +import com.kntro.reqsai.gateway.interfaces.rest.dto.response.ConnectionTestResponse; +import com.kntro.reqsai.gateway.interfaces.rest.dto.response.IntegrationConnectionResponse; +import com.kntro.reqsai.gateway.interfaces.rest.dto.response.JiraIssueTypeResponse; +import com.kntro.reqsai.gateway.interfaces.rest.dto.response.JiraProjectResponse; import com.kntro.reqsai.shared.infrastructure.configuration.ApiVersioning; import com.kntro.reqsai.shared.infrastructure.documentation.openapi.OpenApiConfiguration; import com.kntro.reqsai.shared.infrastructure.documentation.openapi.annotations.ApiResponseBadRequest; diff --git a/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/swagger/ProjectIntegrationController.java b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/swagger/ProjectIntegrationController.java similarity index 93% rename from src/main/java/com/kntro/reqsai/integrations/interfaces/rest/swagger/ProjectIntegrationController.java rename to src/main/java/com/kntro/reqsai/gateway/interfaces/rest/swagger/ProjectIntegrationController.java index 9e76873d..8e20b05b 100644 --- a/src/main/java/com/kntro/reqsai/integrations/interfaces/rest/swagger/ProjectIntegrationController.java +++ b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/swagger/ProjectIntegrationController.java @@ -1,9 +1,9 @@ -package com.kntro.reqsai.integrations.interfaces.rest.swagger; +package com.kntro.reqsai.gateway.interfaces.rest.swagger; -import com.kntro.reqsai.integrations.interfaces.rest.dto.request.SaveProjectTargetRequest; -import com.kntro.reqsai.integrations.interfaces.rest.dto.response.BatchPushResponse; -import com.kntro.reqsai.integrations.interfaces.rest.dto.response.JiraPushResultResponse; -import com.kntro.reqsai.integrations.interfaces.rest.dto.response.ProjectJiraTargetResponse; +import com.kntro.reqsai.gateway.interfaces.rest.dto.request.SaveProjectTargetRequest; +import com.kntro.reqsai.gateway.interfaces.rest.dto.response.BatchPushResponse; +import com.kntro.reqsai.gateway.interfaces.rest.dto.response.JiraPushResultResponse; +import com.kntro.reqsai.gateway.interfaces.rest.dto.response.ProjectJiraTargetResponse; import com.kntro.reqsai.shared.infrastructure.configuration.ApiVersioning; import com.kntro.reqsai.shared.infrastructure.documentation.openapi.OpenApiConfiguration; import com.kntro.reqsai.shared.infrastructure.documentation.openapi.annotations.ApiResponseBadRequest; diff --git a/src/main/java/com/kntro/reqsai/gateway/package-info.java b/src/main/java/com/kntro/reqsai/gateway/package-info.java index 2f03d352..d5d150c9 100644 --- a/src/main/java/com/kntro/reqsai/gateway/package-info.java +++ b/src/main/java/com/kntro/reqsai/gateway/package-info.java @@ -1,10 +1,17 @@ /** - * Gateway — external integrations bounded context. + * Gateway — external integrations bounded context (ADR-0022). *

- * Jira integration (OAuth connection + export of user stories). Owner: Marcelo. + * Third-party tracker connections and story push, whose first provider is Jira Cloud. Extensible + * provider model: credentials live at the organization level + * ({@code IntegrationConnection}, encrypted API token); the push target (Jira project key + issue + * type) lives at the project level ({@code ProjectIntegrationTarget}). + * Owner: Marcelo. *

- * Layers: {@code api}, {@code domain}, {@code application}, {@code infrastructure}, {@code interfaces}. - * Depends only on the OPEN {@code shared} module. + * Layers: {@code domain}, {@code application}, {@code infrastructure}, {@code interfaces}. + * Depends on the OPEN {@code shared} module, the {@code workspace::api} named interface (org/project + * authorization context — {@code @authz} + {@code Permission}) and the {@code discovery::api} named + * interface ({@code DiscoveryStoryReadPort}, reading user stories to push). */ -@org.springframework.modulith.ApplicationModule +@org.springframework.modulith.ApplicationModule( + allowedDependencies = {"shared", "workspace::api", "discovery::api"}) package com.kntro.reqsai.gateway; diff --git a/src/main/java/com/kntro/reqsai/integrations/package-info.java b/src/main/java/com/kntro/reqsai/integrations/package-info.java deleted file mode 100644 index eb0bdff3..00000000 --- a/src/main/java/com/kntro/reqsai/integrations/package-info.java +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Integrations — third-party tracker connections and story push bounded context (ADR-0022). - *

- * Extensible provider model whose first implementation is Jira Cloud. Credentials live at the - * organization level ({@code IntegrationConnection}, encrypted API token); the push - * target (Jira project key + issue type) lives at the project level - * ({@code ProjectIntegrationTarget}). Owners: Jhosepmyr. - *

- * Layers: {@code api}, {@code domain}, {@code application}, {@code infrastructure}, {@code interfaces}. - * Depends on the OPEN {@code shared} module, the {@code workspace::api} named interface (org/project - * authorization context) and the {@code discovery::api} named interface (reading user stories to push). - */ -@org.springframework.modulith.ApplicationModule( - allowedDependencies = {"shared", "workspace::api", "discovery::api"}) -package com.kntro.reqsai.integrations; diff --git a/src/test/java/com/kntro/reqsai/architecture/ArchitectureTests.java b/src/test/java/com/kntro/reqsai/architecture/ArchitectureTests.java index 8337c247..0853547f 100644 --- a/src/test/java/com/kntro/reqsai/architecture/ArchitectureTests.java +++ b/src/test/java/com/kntro/reqsai/architecture/ArchitectureTests.java @@ -23,7 +23,7 @@ class ArchitectureTests { "..workspace.domain.exception..", "..iam.domain.exception..", "..billing.domain.exception..", - "..integrations.domain.exception..") + "..gateway.domain.exception..") .should().dependOnClassesThat().resideInAPackage("org.springframework..") .because("domain layer must be framework-agnostic; " + "shared.domain.model uses Spring Data auditing intentionally, " @@ -41,7 +41,7 @@ class ArchitectureTests { "..iam.domain.model..", "..billing.domain.model..", "..billing.domain.model.valueobjects..", - "..integrations.domain.model..") + "..gateway.domain.model..") .should().dependOnClassesThat().resideInAPackage("jakarta.persistence..") .because("domain must not depend on JPA — use ports; " + "Active Record pattern exempts model and value-object packages"); diff --git a/src/test/java/com/kntro/reqsai/integrations/StubJiraProviderConfig.java b/src/test/java/com/kntro/reqsai/gateway/StubJiraProviderConfig.java similarity index 90% rename from src/test/java/com/kntro/reqsai/integrations/StubJiraProviderConfig.java rename to src/test/java/com/kntro/reqsai/gateway/StubJiraProviderConfig.java index 94ee2755..0e8c56dd 100644 --- a/src/test/java/com/kntro/reqsai/integrations/StubJiraProviderConfig.java +++ b/src/test/java/com/kntro/reqsai/gateway/StubJiraProviderConfig.java @@ -1,8 +1,8 @@ -package com.kntro.reqsai.integrations; +package com.kntro.reqsai.gateway; import com.kntro.reqsai.discovery.api.StoryView; -import com.kntro.reqsai.integrations.application.port.IntegrationProvider; -import com.kntro.reqsai.integrations.domain.model.IntegrationProviderType; +import com.kntro.reqsai.gateway.application.port.IntegrationProvider; +import com.kntro.reqsai.gateway.domain.model.IntegrationProviderType; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Primary; diff --git a/src/test/java/com/kntro/reqsai/integrations/application/handler/ConnectJiraCommandHandlerTest.java b/src/test/java/com/kntro/reqsai/gateway/application/handler/ConnectJiraCommandHandlerTest.java similarity index 84% rename from src/test/java/com/kntro/reqsai/integrations/application/handler/ConnectJiraCommandHandlerTest.java rename to src/test/java/com/kntro/reqsai/gateway/application/handler/ConnectJiraCommandHandlerTest.java index 47c56093..4949763b 100644 --- a/src/test/java/com/kntro/reqsai/integrations/application/handler/ConnectJiraCommandHandlerTest.java +++ b/src/test/java/com/kntro/reqsai/gateway/application/handler/ConnectJiraCommandHandlerTest.java @@ -1,13 +1,13 @@ -package com.kntro.reqsai.integrations.application.handler; - -import com.kntro.reqsai.integrations.application.command.ConnectJiraCommand; -import com.kntro.reqsai.integrations.application.port.IntegrationConnectionRepository; -import com.kntro.reqsai.integrations.application.port.IntegrationProvider; -import com.kntro.reqsai.integrations.application.service.ProviderRegistry; -import com.kntro.reqsai.integrations.domain.model.ConnectionStatus; -import com.kntro.reqsai.integrations.domain.model.IntegrationConnection; -import com.kntro.reqsai.integrations.domain.model.IntegrationProviderType; -import com.kntro.reqsai.integrations.infrastructure.exception.IntegrationsInfrastructureExceptions; +package com.kntro.reqsai.gateway.application.handler; + +import com.kntro.reqsai.gateway.application.command.ConnectJiraCommand; +import com.kntro.reqsai.gateway.application.port.IntegrationConnectionRepository; +import com.kntro.reqsai.gateway.application.port.IntegrationProvider; +import com.kntro.reqsai.gateway.application.service.ProviderRegistry; +import com.kntro.reqsai.gateway.domain.model.ConnectionStatus; +import com.kntro.reqsai.gateway.domain.model.IntegrationConnection; +import com.kntro.reqsai.gateway.domain.model.IntegrationProviderType; +import com.kntro.reqsai.gateway.infrastructure.exception.IntegrationsInfrastructureExceptions; import com.kntro.reqsai.shared.domain.exception.DomainException; import com.kntro.reqsai.shared.domain.exception.InfrastructureException; import org.junit.jupiter.api.BeforeEach; diff --git a/src/test/java/com/kntro/reqsai/integrations/application/handler/PushAllStoriesCommandHandlerTest.java b/src/test/java/com/kntro/reqsai/gateway/application/handler/PushAllStoriesCommandHandlerTest.java similarity index 75% rename from src/test/java/com/kntro/reqsai/integrations/application/handler/PushAllStoriesCommandHandlerTest.java rename to src/test/java/com/kntro/reqsai/gateway/application/handler/PushAllStoriesCommandHandlerTest.java index 61e77ed0..99bdf674 100644 --- a/src/test/java/com/kntro/reqsai/integrations/application/handler/PushAllStoriesCommandHandlerTest.java +++ b/src/test/java/com/kntro/reqsai/gateway/application/handler/PushAllStoriesCommandHandlerTest.java @@ -1,21 +1,21 @@ -package com.kntro.reqsai.integrations.application.handler; +package com.kntro.reqsai.gateway.application.handler; import com.kntro.reqsai.discovery.api.DiscoveryStoryReadPort; import com.kntro.reqsai.discovery.api.StoryView; -import com.kntro.reqsai.integrations.application.command.PushAllStoriesCommand; -import com.kntro.reqsai.integrations.application.port.IntegrationConnectionRepository; -import com.kntro.reqsai.integrations.application.port.IntegrationProvider; -import com.kntro.reqsai.integrations.application.port.IntegrationProvider.PushedIssue; -import com.kntro.reqsai.integrations.application.port.ProjectIntegrationTargetRepository; -import com.kntro.reqsai.integrations.application.result.BatchPushResult; -import com.kntro.reqsai.integrations.application.service.ProviderCredentialsFactory; -import com.kntro.reqsai.integrations.application.service.ProviderRegistry; -import com.kntro.reqsai.integrations.application.service.StoryPushService; -import com.kntro.reqsai.integrations.domain.model.ConnectionStatus; -import com.kntro.reqsai.integrations.domain.model.IntegrationConnection; -import com.kntro.reqsai.integrations.domain.model.IntegrationProviderType; -import com.kntro.reqsai.integrations.domain.model.ProjectIntegrationTarget; -import com.kntro.reqsai.integrations.infrastructure.exception.IntegrationsInfrastructureExceptions; +import com.kntro.reqsai.gateway.application.command.PushAllStoriesCommand; +import com.kntro.reqsai.gateway.application.port.IntegrationConnectionRepository; +import com.kntro.reqsai.gateway.application.port.IntegrationProvider; +import com.kntro.reqsai.gateway.application.port.IntegrationProvider.PushedIssue; +import com.kntro.reqsai.gateway.application.port.ProjectIntegrationTargetRepository; +import com.kntro.reqsai.gateway.application.result.BatchPushResult; +import com.kntro.reqsai.gateway.application.service.ProviderCredentialsFactory; +import com.kntro.reqsai.gateway.application.service.ProviderRegistry; +import com.kntro.reqsai.gateway.application.service.StoryPushService; +import com.kntro.reqsai.gateway.domain.model.ConnectionStatus; +import com.kntro.reqsai.gateway.domain.model.IntegrationConnection; +import com.kntro.reqsai.gateway.domain.model.IntegrationProviderType; +import com.kntro.reqsai.gateway.domain.model.ProjectIntegrationTarget; +import com.kntro.reqsai.gateway.infrastructure.exception.IntegrationsInfrastructureExceptions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/src/test/java/com/kntro/reqsai/integrations/application/handler/PushStoryCommandHandlerTest.java b/src/test/java/com/kntro/reqsai/gateway/application/handler/PushStoryCommandHandlerTest.java similarity index 78% rename from src/test/java/com/kntro/reqsai/integrations/application/handler/PushStoryCommandHandlerTest.java rename to src/test/java/com/kntro/reqsai/gateway/application/handler/PushStoryCommandHandlerTest.java index 9b3196a7..524a463b 100644 --- a/src/test/java/com/kntro/reqsai/integrations/application/handler/PushStoryCommandHandlerTest.java +++ b/src/test/java/com/kntro/reqsai/gateway/application/handler/PushStoryCommandHandlerTest.java @@ -1,19 +1,19 @@ -package com.kntro.reqsai.integrations.application.handler; +package com.kntro.reqsai.gateway.application.handler; import com.kntro.reqsai.discovery.api.DiscoveryStoryReadPort; import com.kntro.reqsai.discovery.api.StoryView; -import com.kntro.reqsai.integrations.application.command.PushStoryCommand; -import com.kntro.reqsai.integrations.application.port.IntegrationConnectionRepository; -import com.kntro.reqsai.integrations.application.port.IntegrationProvider; -import com.kntro.reqsai.integrations.application.port.IntegrationProvider.PushedIssue; -import com.kntro.reqsai.integrations.application.port.ProjectIntegrationTargetRepository; -import com.kntro.reqsai.integrations.application.result.StoryPushResult; -import com.kntro.reqsai.integrations.application.service.ProviderCredentialsFactory; -import com.kntro.reqsai.integrations.application.service.ProviderRegistry; -import com.kntro.reqsai.integrations.application.service.StoryPushService; -import com.kntro.reqsai.integrations.domain.model.IntegrationConnection; -import com.kntro.reqsai.integrations.domain.model.IntegrationProviderType; -import com.kntro.reqsai.integrations.domain.model.ProjectIntegrationTarget; +import com.kntro.reqsai.gateway.application.command.PushStoryCommand; +import com.kntro.reqsai.gateway.application.port.IntegrationConnectionRepository; +import com.kntro.reqsai.gateway.application.port.IntegrationProvider; +import com.kntro.reqsai.gateway.application.port.IntegrationProvider.PushedIssue; +import com.kntro.reqsai.gateway.application.port.ProjectIntegrationTargetRepository; +import com.kntro.reqsai.gateway.application.result.StoryPushResult; +import com.kntro.reqsai.gateway.application.service.ProviderCredentialsFactory; +import com.kntro.reqsai.gateway.application.service.ProviderRegistry; +import com.kntro.reqsai.gateway.application.service.StoryPushService; +import com.kntro.reqsai.gateway.domain.model.IntegrationConnection; +import com.kntro.reqsai.gateway.domain.model.IntegrationProviderType; +import com.kntro.reqsai.gateway.domain.model.ProjectIntegrationTarget; import com.kntro.reqsai.shared.domain.exception.DomainException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; diff --git a/src/test/java/com/kntro/reqsai/integrations/application/handler/TestConnectionQueryHandlerTest.java b/src/test/java/com/kntro/reqsai/gateway/application/handler/TestConnectionQueryHandlerTest.java similarity index 79% rename from src/test/java/com/kntro/reqsai/integrations/application/handler/TestConnectionQueryHandlerTest.java rename to src/test/java/com/kntro/reqsai/gateway/application/handler/TestConnectionQueryHandlerTest.java index bfe88252..4d3ceb1f 100644 --- a/src/test/java/com/kntro/reqsai/integrations/application/handler/TestConnectionQueryHandlerTest.java +++ b/src/test/java/com/kntro/reqsai/gateway/application/handler/TestConnectionQueryHandlerTest.java @@ -1,15 +1,15 @@ -package com.kntro.reqsai.integrations.application.handler; +package com.kntro.reqsai.gateway.application.handler; -import com.kntro.reqsai.integrations.application.port.IntegrationConnectionRepository; -import com.kntro.reqsai.integrations.application.port.IntegrationProvider; -import com.kntro.reqsai.integrations.application.query.TestConnectionQuery; -import com.kntro.reqsai.integrations.application.result.ConnectionTestResult; -import com.kntro.reqsai.integrations.application.service.ProviderCredentialsFactory; -import com.kntro.reqsai.integrations.application.service.ProviderRegistry; -import com.kntro.reqsai.integrations.domain.model.ConnectionStatus; -import com.kntro.reqsai.integrations.domain.model.IntegrationConnection; -import com.kntro.reqsai.integrations.domain.model.IntegrationProviderType; -import com.kntro.reqsai.integrations.infrastructure.exception.IntegrationsInfrastructureExceptions; +import com.kntro.reqsai.gateway.application.port.IntegrationConnectionRepository; +import com.kntro.reqsai.gateway.application.port.IntegrationProvider; +import com.kntro.reqsai.gateway.application.query.TestConnectionQuery; +import com.kntro.reqsai.gateway.application.result.ConnectionTestResult; +import com.kntro.reqsai.gateway.application.service.ProviderCredentialsFactory; +import com.kntro.reqsai.gateway.application.service.ProviderRegistry; +import com.kntro.reqsai.gateway.domain.model.ConnectionStatus; +import com.kntro.reqsai.gateway.domain.model.IntegrationConnection; +import com.kntro.reqsai.gateway.domain.model.IntegrationProviderType; +import com.kntro.reqsai.gateway.infrastructure.exception.IntegrationsInfrastructureExceptions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/src/test/java/com/kntro/reqsai/integrations/infrastructure/crypto/AesGcmCipherTest.java b/src/test/java/com/kntro/reqsai/gateway/infrastructure/crypto/AesGcmCipherTest.java similarity index 97% rename from src/test/java/com/kntro/reqsai/integrations/infrastructure/crypto/AesGcmCipherTest.java rename to src/test/java/com/kntro/reqsai/gateway/infrastructure/crypto/AesGcmCipherTest.java index c1831b49..d7b07008 100644 --- a/src/test/java/com/kntro/reqsai/integrations/infrastructure/crypto/AesGcmCipherTest.java +++ b/src/test/java/com/kntro/reqsai/gateway/infrastructure/crypto/AesGcmCipherTest.java @@ -1,4 +1,4 @@ -package com.kntro.reqsai.integrations.infrastructure.crypto; +package com.kntro.reqsai.gateway.infrastructure.crypto; import com.kntro.reqsai.shared.domain.exception.InfrastructureException; import org.junit.jupiter.api.DisplayName; diff --git a/src/test/java/com/kntro/reqsai/integrations/infrastructure/jira/JiraAdfBuilderTest.java b/src/test/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraAdfBuilderTest.java similarity index 97% rename from src/test/java/com/kntro/reqsai/integrations/infrastructure/jira/JiraAdfBuilderTest.java rename to src/test/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraAdfBuilderTest.java index 590d6859..f51a8a90 100644 --- a/src/test/java/com/kntro/reqsai/integrations/infrastructure/jira/JiraAdfBuilderTest.java +++ b/src/test/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraAdfBuilderTest.java @@ -1,4 +1,4 @@ -package com.kntro.reqsai.integrations.infrastructure.jira; +package com.kntro.reqsai.gateway.infrastructure.jira; import com.kntro.reqsai.discovery.api.AcceptanceCriterionView; import com.kntro.reqsai.discovery.api.StoryView; diff --git a/src/test/java/com/kntro/reqsai/integrations/interfaces/rest/JiraIntegrationPushIntegrationTest.java b/src/test/java/com/kntro/reqsai/gateway/interfaces/rest/JiraIntegrationPushIntegrationTest.java similarity index 98% rename from src/test/java/com/kntro/reqsai/integrations/interfaces/rest/JiraIntegrationPushIntegrationTest.java rename to src/test/java/com/kntro/reqsai/gateway/interfaces/rest/JiraIntegrationPushIntegrationTest.java index 8dba42d8..caa1fd5d 100644 --- a/src/test/java/com/kntro/reqsai/integrations/interfaces/rest/JiraIntegrationPushIntegrationTest.java +++ b/src/test/java/com/kntro/reqsai/gateway/interfaces/rest/JiraIntegrationPushIntegrationTest.java @@ -1,8 +1,8 @@ -package com.kntro.reqsai.integrations.interfaces.rest; +package com.kntro.reqsai.gateway.interfaces.rest; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import com.kntro.reqsai.integrations.StubJiraProviderConfig; +import com.kntro.reqsai.gateway.StubJiraProviderConfig; import com.kntro.reqsai.testsupport.AbstractIntegrationTest; import com.kntro.reqsai.testsupport.StubEmbeddingConfig; import com.kntro.reqsai.testsupport.TestJwtFactory; From ca7112b346560a1667c583de181df81f71bd4466 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Mon, 6 Jul 2026 20:08:38 -0500 Subject: [PATCH 27/72] refactor(gateway): split integration tenant migration into V21 connections and V22 targets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One table per migration script: V21 creates integration_connections (org-scoped credentials), V22 creates project_integration_targets (project-scoped push routing). No schema change — the combined V21 is split for clarity and ordering. --- .../tenant/V21__integration_connections.sql | 21 +------------------ .../V22__project_integration_targets.sql | 19 +++++++++++++++++ 2 files changed, 20 insertions(+), 20 deletions(-) create mode 100644 src/main/resources/db/migration/tenant/V22__project_integration_targets.sql diff --git a/src/main/resources/db/migration/tenant/V21__integration_connections.sql b/src/main/resources/db/migration/tenant/V21__integration_connections.sql index f6e30097..b21a8846 100644 --- a/src/main/resources/db/migration/tenant/V21__integration_connections.sql +++ b/src/main/resources/db/migration/tenant/V21__integration_connections.sql @@ -1,5 +1,4 @@ --- Third-party integrations (ADR-0022). --- Connections are ORG-scoped (credentials, encrypted); targets are PROJECT-scoped (push routing). +-- Third-party integration connections (ADR-0022): ORG-scoped credentials, encrypted at rest. CREATE TABLE integration_connections ( id UUID NOT NULL PRIMARY KEY, @@ -22,21 +21,3 @@ CREATE INDEX idx_integration_connections_org ON integration_connections (organiz CREATE UNIQUE INDEX uq_integration_connections_active_org_provider ON integration_connections (organization_id, provider) WHERE status <> 'DISCONNECTED'; - -CREATE TABLE project_integration_targets ( - id UUID NOT NULL PRIMARY KEY, - project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, - connection_id UUID NOT NULL REFERENCES integration_connections(id) ON DELETE CASCADE, - jira_project_key VARCHAR(100) NOT NULL, - issue_type_name VARCHAR(100) NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - created_by UUID, - updated_by UUID -); - --- One integration target per project (the PUT .../target endpoint upserts this single row). -CREATE UNIQUE INDEX uq_project_integration_targets_project - ON project_integration_targets (project_id); - -CREATE INDEX idx_project_integration_targets_connection ON project_integration_targets (connection_id); diff --git a/src/main/resources/db/migration/tenant/V22__project_integration_targets.sql b/src/main/resources/db/migration/tenant/V22__project_integration_targets.sql new file mode 100644 index 00000000..cc69d4da --- /dev/null +++ b/src/main/resources/db/migration/tenant/V22__project_integration_targets.sql @@ -0,0 +1,19 @@ +-- Project-scoped Jira push routing (ADR-0022): one target per project, pointing at an org connection. + +CREATE TABLE project_integration_targets ( + id UUID NOT NULL PRIMARY KEY, + project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + connection_id UUID NOT NULL REFERENCES integration_connections(id) ON DELETE CASCADE, + jira_project_key VARCHAR(100) NOT NULL, + issue_type_name VARCHAR(100) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_by UUID, + updated_by UUID +); + +-- One integration target per project (the PUT .../target endpoint upserts this single row). +CREATE UNIQUE INDEX uq_project_integration_targets_project + ON project_integration_targets (project_id); + +CREATE INDEX idx_project_integration_targets_connection ON project_integration_targets (connection_id); From 1cfb046d114e3ff8f7c35d965a3d5b35cd2d5048 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Mon, 6 Jul 2026 20:18:23 -0500 Subject: [PATCH 28/72] feat(gateway): add oauth2 credential type to integration connections introduce CredentialType {API_TOKEN, OAUTH2} and extend IntegrationConnection with cloudId + encrypted oauth refresh/access tokens and expiry, plus a factory for oauth connections and a rotated-token updater. migration V23 adds the columns additively (default API_TOKEN) and relaxes email/secret_ciphertext to nullable. IntegrationConnectionResponse now carries credentialType and email may be null. --- .../gateway/domain/model/CredentialType.java | 22 ++++ .../domain/model/IntegrationConnection.java | 102 ++++++++++++++++-- .../IntegrationConnectionResponse.java | 14 ++- .../response/IntegrationResponseMapper.java | 1 + .../V23__integration_connections_oauth.sql | 28 +++++ 5 files changed, 154 insertions(+), 13 deletions(-) create mode 100644 src/main/java/com/kntro/reqsai/gateway/domain/model/CredentialType.java create mode 100644 src/main/resources/db/migration/tenant/V23__integration_connections_oauth.sql diff --git a/src/main/java/com/kntro/reqsai/gateway/domain/model/CredentialType.java b/src/main/java/com/kntro/reqsai/gateway/domain/model/CredentialType.java new file mode 100644 index 00000000..d9fecaeb --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/domain/model/CredentialType.java @@ -0,0 +1,22 @@ +package com.kntro.reqsai.gateway.domain.model; + +/** + * How an {@link IntegrationConnection} authenticates against its provider (ADR-0022). + *

    + *
  • {@code API_TOKEN} — Jira basic auth: {@code Authorization: Basic base64(email:token)} against + * {@code https://{site}/rest/api/3}. The {@code email} + encrypted {@code secret_ciphertext} are + * populated; the OAuth columns are null.
  • + *
  • {@code OAUTH2} — Jira OAuth 2.0 (3LO): {@code Authorization: Bearer {access}} against + * {@code https://api.atlassian.com/ex/jira/{cloudId}/rest/api/3}. The {@code cloud_id} + encrypted + * OAuth refresh/access tokens are populated; {@code email} + {@code secret_ciphertext} are null.
  • + *
+ * Exactly one credential shape is populated per value (application-enforced by the domain factory). + */ +public enum CredentialType { + + /** Jira API token + account email over basic auth (the original flow). */ + API_TOKEN, + + /** Jira OAuth 2.0 (3LO) refresh/access tokens over bearer auth. */ + OAUTH2 +} diff --git a/src/main/java/com/kntro/reqsai/gateway/domain/model/IntegrationConnection.java b/src/main/java/com/kntro/reqsai/gateway/domain/model/IntegrationConnection.java index 5f953b57..2ba23ce1 100644 --- a/src/main/java/com/kntro/reqsai/gateway/domain/model/IntegrationConnection.java +++ b/src/main/java/com/kntro/reqsai/gateway/domain/model/IntegrationConnection.java @@ -17,9 +17,16 @@ /** * Organization-scoped third-party integration connection (ADR-0022). Holds the provider, the Jira site - * URL + account email, and the API token encrypted at rest (the {@code apiToken} field - * is transparently encrypted/decrypted by {@link EncryptedStringConverter} into the - * {@code secret_ciphertext} BYTEA column). The token is never exposed by any response mapper. + * URL, and one of two credential shapes selected by {@link #credentialType}: + *
    + *
  • {@link CredentialType#API_TOKEN} — account {@code email} + the API token + * encrypted at rest ({@code apiToken} → {@code secret_ciphertext} BYTEA).
  • + *
  • {@link CredentialType#OAUTH2} — the Atlassian {@code cloudId} + the OAuth refresh/access tokens + * encrypted at rest ({@code oauth_refresh_ciphertext} / {@code oauth_access_ciphertext}) + * plus the access-token expiry.
  • + *
+ * All secrets are transparently encrypted/decrypted by {@link EncryptedStringConverter} and are never + * exposed by any response mapper. */ @Entity @Table(name = "integration_connections") @@ -28,6 +35,7 @@ public class IntegrationConnection extends AggregateRoot { private static final int SITE_URL_MAX = 500; private static final int EMAIL_MAX = 320; + private static final int CLOUD_ID_MAX = 64; @Column(name = "organization_id", columnDefinition = "uuid", nullable = false, updatable = false) private UUID organizationId; @@ -36,16 +44,39 @@ public class IntegrationConnection extends AggregateRoot { @Column(name = "provider", nullable = false, length = 32, updatable = false) private IntegrationProviderType provider; + @Enumerated(EnumType.STRING) + @Column(name = "credential_type", nullable = false, length = 32, updatable = false) + private CredentialType credentialType; + @Column(name = "site_url", nullable = false, length = SITE_URL_MAX) private String siteUrl; - @Column(name = "email", nullable = false, length = EMAIL_MAX) - private String email; + /** Populated for {@link CredentialType#API_TOKEN}; null for OAuth. */ + @Column(name = "email", length = EMAIL_MAX) + private @Nullable String email; + + /** Plaintext in memory only; persisted encrypted via {@link EncryptedStringConverter}. API_TOKEN only. */ + @Convert(converter = EncryptedStringConverter.class) + @Column(name = "secret_ciphertext") + private @Nullable String apiToken; + + /** Atlassian cloud id (site id) for OAuth calls. Populated for {@link CredentialType#OAUTH2}. */ + @Column(name = "cloud_id", length = CLOUD_ID_MAX) + private @Nullable String cloudId; - /** Plaintext in memory only; persisted encrypted via {@link EncryptedStringConverter}. */ + /** OAuth refresh token (plaintext in memory only; persisted encrypted). OAUTH2 only. */ @Convert(converter = EncryptedStringConverter.class) - @Column(name = "secret_ciphertext", nullable = false) - private String apiToken; + @Column(name = "oauth_refresh_ciphertext") + private @Nullable String oauthRefreshToken; + + /** OAuth access token (plaintext in memory only; persisted encrypted). OAUTH2 only. */ + @Convert(converter = EncryptedStringConverter.class) + @Column(name = "oauth_access_ciphertext") + private @Nullable String oauthAccessToken; + + /** When the current OAuth access token expires; used to decide when to refresh. OAUTH2 only. */ + @Column(name = "oauth_access_expires_at") + private @Nullable Instant oauthAccessExpiresAt; @Enumerated(EnumType.STRING) @Column(name = "status", nullable = false, length = 32) @@ -59,14 +90,16 @@ protected IntegrationConnection() { } /** - * Creates a Jira connection. The caller is expected to have verified the credential (via the - * provider) before persisting; {@code verifiedAt} records that success. + * Creates an API-token ({@link CredentialType#API_TOKEN}) Jira connection. The caller is expected to + * have verified the credential (via the provider) before persisting; {@code verifiedAt} records that + * success. */ public IntegrationConnection(UUID organizationId, IntegrationProviderType provider, String siteUrl, String email, String apiToken, Instant verifiedAt) { super(); this.organizationId = Assert.notNull(organizationId, "organizationId"); this.provider = Assert.notNull(provider, "provider"); + this.credentialType = CredentialType.API_TOKEN; this.siteUrl = normalizeSiteUrl(siteUrl); this.email = Assert.maxLength(Assert.notBlank(email, "email"), "email", EMAIL_MAX); this.apiToken = Assert.notBlank(apiToken, "apiToken"); @@ -74,6 +107,55 @@ public IntegrationConnection(UUID organizationId, IntegrationProviderType provid this.lastVerifiedAt = Assert.notNull(verifiedAt, "verifiedAt"); } + /** + * Creates an OAuth 2.0 (3LO) ({@link CredentialType#OAUTH2}) Jira connection from a completed token + * exchange. {@code siteUrl} is the discovered accessible-resource URL, {@code cloudId} its site id. + * The refresh token is required (obtained via the {@code offline_access} scope); the access token + + * its expiry are cached so the first call need not refresh. + */ + public static IntegrationConnection oauth(UUID organizationId, IntegrationProviderType provider, + String siteUrl, String cloudId, String refreshToken, + String accessToken, Instant accessExpiresAt, Instant verifiedAt) { + IntegrationConnection c = new IntegrationConnection(); + c.organizationId = Assert.notNull(organizationId, "organizationId"); + c.provider = Assert.notNull(provider, "provider"); + c.credentialType = CredentialType.OAUTH2; + c.siteUrl = normalizeSiteUrl(siteUrl); + c.cloudId = Assert.maxLength(Assert.notBlank(cloudId, "cloudId"), "cloudId", CLOUD_ID_MAX); + c.oauthRefreshToken = Assert.notBlank(refreshToken, "refreshToken"); + c.oauthAccessToken = Assert.notBlank(accessToken, "accessToken"); + c.oauthAccessExpiresAt = Assert.notNull(accessExpiresAt, "accessExpiresAt"); + c.status = ConnectionStatus.CONNECTED; + c.lastVerifiedAt = Assert.notNull(verifiedAt, "verifiedAt"); + return c; + } + + /** + * Applies rotated OAuth tokens after a refresh. Atlassian may return a new (rotated) refresh token; if + * so it is persisted, otherwise the existing refresh token is kept. The fresh access token + expiry + * replace the cached pair. No-op semantics for API-token connections is prevented by the caller. + */ + public void applyRefreshedTokens(@Nullable String rotatedRefreshToken, String accessToken, + Instant accessExpiresAt) { + Assert.isTrue(credentialType == CredentialType.OAUTH2, "credentialType", + "applyRefreshedTokens requires an OAUTH2 credential"); + if (rotatedRefreshToken != null && !rotatedRefreshToken.isBlank()) { + this.oauthRefreshToken = rotatedRefreshToken; + } + this.oauthAccessToken = Assert.notBlank(accessToken, "accessToken"); + this.oauthAccessExpiresAt = Assert.notNull(accessExpiresAt, "accessExpiresAt"); + this.status = ConnectionStatus.CONNECTED; + } + + /** True when the OAuth access token is missing, expired, or within {@code skew} of expiring. */ + public boolean oauthAccessExpiredWithin(java.time.Duration skew, Instant now) { + if (credentialType != CredentialType.OAUTH2) { + return false; + } + return oauthAccessToken == null || oauthAccessExpiresAt == null + || !oauthAccessExpiresAt.isAfter(now.plus(skew)); + } + /** Normalizes the Jira base site URL, trimming a trailing slash so path concatenation is clean. */ public static String normalizeSiteUrl(String siteUrl) { String trimmed = Assert.maxLength(Assert.notBlank(siteUrl, "siteUrl"), "siteUrl", SITE_URL_MAX); diff --git a/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/IntegrationConnectionResponse.java b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/IntegrationConnectionResponse.java index 41c45c4c..eb12baec 100644 --- a/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/IntegrationConnectionResponse.java +++ b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/IntegrationConnectionResponse.java @@ -6,14 +6,22 @@ import java.time.Instant; import java.util.UUID; -/** Organization integration connection resource. NEVER carries the API token. */ -@Schema(description = "Organization integration connection (the API token is never returned)") +/** + * Organization integration connection resource. NEVER carries the API token or any OAuth token. + *

+ * {@code credentialType} is {@code "API_TOKEN"} or {@code "OAUTH2"}; {@code email} is populated only for + * {@code API_TOKEN} connections and is {@code null} for {@code OAUTH2}. + */ +@Schema(description = "Organization integration connection (no API/OAuth token is ever returned)") public record IntegrationConnectionResponse( UUID id, UUID organizationId, String provider, + @Schema(description = "Credential type", allowableValues = {"API_TOKEN", "OAUTH2"}) + String credentialType, String siteUrl, - String email, + @Schema(description = "Jira account email; null for OAUTH2 connections") + @Nullable String email, String status, @Nullable Instant lastVerifiedAt, Instant createdAt, diff --git a/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/mappers/response/IntegrationResponseMapper.java b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/mappers/response/IntegrationResponseMapper.java index b05e4309..b7daddcf 100644 --- a/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/mappers/response/IntegrationResponseMapper.java +++ b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/mappers/response/IntegrationResponseMapper.java @@ -27,6 +27,7 @@ public static IntegrationConnectionResponse toResponse(IntegrationConnection c) c.getId(), c.getOrganizationId(), c.getProvider().name(), + c.getCredentialType().name(), c.getSiteUrl(), c.getEmail(), c.getStatus().name(), diff --git a/src/main/resources/db/migration/tenant/V23__integration_connections_oauth.sql b/src/main/resources/db/migration/tenant/V23__integration_connections_oauth.sql new file mode 100644 index 00000000..93a23d33 --- /dev/null +++ b/src/main/resources/db/migration/tenant/V23__integration_connections_oauth.sql @@ -0,0 +1,28 @@ +-- Jira Cloud OAuth 2.0 (3LO) support alongside the existing API-token flow (ADR-0022). +-- Additive & backward-compatible: existing rows default to credential_type = 'API_TOKEN' and keep their +-- email + secret_ciphertext. OAuth rows carry cloud_id + encrypted refresh/access tokens instead. +-- +-- Invariant (application-enforced): exactly one credential shape is populated per credential_type — +-- API_TOKEN -> (email, secret_ciphertext) NOT NULL, oauth_* NULL +-- OAUTH2 -> (cloud_id, oauth_refresh_ciphertext) NOT NULL, email + secret_ciphertext NULL +-- site_url stays NOT NULL for both (OAuth sets it from the discovered accessible-resource site URL). + +ALTER TABLE integration_connections + ADD COLUMN credential_type VARCHAR(32) NOT NULL DEFAULT 'API_TOKEN'; + +ALTER TABLE integration_connections + ADD COLUMN cloud_id VARCHAR(64); + +ALTER TABLE integration_connections + ADD COLUMN oauth_refresh_ciphertext BYTEA; + +ALTER TABLE integration_connections + ADD COLUMN oauth_access_ciphertext BYTEA; + +ALTER TABLE integration_connections + ADD COLUMN oauth_access_expires_at TIMESTAMPTZ; + +-- Relax the API-token-only NOT NULL constraints so OAuth connections (no basic-auth email/token) fit the +-- shared table. The one-populated-shape-per-credential_type invariant is enforced in the domain factory. +ALTER TABLE integration_connections ALTER COLUMN email DROP NOT NULL; +ALTER TABLE integration_connections ALTER COLUMN secret_ciphertext DROP NOT NULL; From e932df4abda082d6dd15ecd89a653532479eda33 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Mon, 6 Jul 2026 20:21:28 -0500 Subject: [PATCH 29/72] feat(gateway): add jira oauth error codes and optional oauth config new error codes JIRA_OAUTH_NOT_CONFIGURED (501), JIRA_OAUTH_STATE_INVALID (400) in the domain enum and JIRA_OAUTH_EXCHANGE_FAILED (502) in the infrastructure enum, each with a factory method. bind reqsai.integrations.jira.oauth.* via optional JiraOAuthProperties (blank client-id/secret/redirect => not configured, app still boots). add dev/test dummy values so the suite and local run stay green. --- .../domain/exception/IntegrationsError.java | 8 +++- .../exception/IntegrationsExceptions.java | 12 ++++++ .../IntegrationsInfrastructureError.java | 5 ++- .../IntegrationsInfrastructureExceptions.java | 6 +++ .../jira/JiraOAuthProperties.java | 41 +++++++++++++++++++ src/main/resources/application-dev.yml | 8 ++++ src/main/resources/application.yml | 11 +++++ src/test/resources/application-test.yml | 8 ++++ 8 files changed, 97 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraOAuthProperties.java diff --git a/src/main/java/com/kntro/reqsai/gateway/domain/exception/IntegrationsError.java b/src/main/java/com/kntro/reqsai/gateway/domain/exception/IntegrationsError.java index 40510f76..9ae352a8 100644 --- a/src/main/java/com/kntro/reqsai/gateway/domain/exception/IntegrationsError.java +++ b/src/main/java/com/kntro/reqsai/gateway/domain/exception/IntegrationsError.java @@ -13,7 +13,13 @@ public enum IntegrationsError implements ErrorCatalog { INTEGRATION_CONNECTION_NOT_FOUND(HttpStatus.NOT_FOUND), INTEGRATION_ALREADY_CONNECTED(HttpStatus.CONFLICT), INTEGRATION_TARGET_NOT_CONFIGURED(HttpStatus.CONFLICT), - JIRA_PROJECT_NOT_FOUND(HttpStatus.NOT_FOUND); + JIRA_PROJECT_NOT_FOUND(HttpStatus.NOT_FOUND), + + /** Jira OAuth 2.0 (3LO) is not configured on this deployment (client id/secret/redirect absent). */ + JIRA_OAUTH_NOT_CONFIGURED(HttpStatus.NOT_IMPLEMENTED), + + /** The OAuth {@code state} token failed validation (bad signature, expired, or wrong org/user). */ + JIRA_OAUTH_STATE_INVALID(HttpStatus.BAD_REQUEST); private final HttpStatus status; diff --git a/src/main/java/com/kntro/reqsai/gateway/domain/exception/IntegrationsExceptions.java b/src/main/java/com/kntro/reqsai/gateway/domain/exception/IntegrationsExceptions.java index 2376c336..d922da97 100644 --- a/src/main/java/com/kntro/reqsai/gateway/domain/exception/IntegrationsExceptions.java +++ b/src/main/java/com/kntro/reqsai/gateway/domain/exception/IntegrationsExceptions.java @@ -47,4 +47,16 @@ public static EntityNotFoundException storyNotFound(UUID storyId) { return new EntityNotFoundException(IntegrationsError.INTEGRATION_CONNECTION_NOT_FOUND, "Story not found in project: " + storyId); } + + /** Jira OAuth is not configured on this deployment — the authorize/callback endpoints are unavailable. */ + public static DomainException oauthNotConfigured() { + return new DomainException(IntegrationsError.JIRA_OAUTH_NOT_CONFIGURED, + "Jira OAuth 2.0 (3LO) is not configured on this deployment"); + } + + /** The OAuth {@code state} token failed validation (signature/expiry/org-user mismatch). */ + public static DomainException oauthStateInvalid(String reason) { + return new DomainException(IntegrationsError.JIRA_OAUTH_STATE_INVALID, + "Invalid OAuth state: " + reason); + } } diff --git a/src/main/java/com/kntro/reqsai/gateway/infrastructure/exception/IntegrationsInfrastructureError.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/exception/IntegrationsInfrastructureError.java index 69930f15..6e6bde22 100644 --- a/src/main/java/com/kntro/reqsai/gateway/infrastructure/exception/IntegrationsInfrastructureError.java +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/exception/IntegrationsInfrastructureError.java @@ -13,7 +13,10 @@ public enum IntegrationsInfrastructureError implements ErrorCatalog { JIRA_AUTH_FAILED(HttpStatus.UNAUTHORIZED), JIRA_UNREACHABLE(HttpStatus.BAD_GATEWAY), JIRA_PUSH_FAILED(HttpStatus.BAD_GATEWAY), - INTEGRATION_ENCRYPTION_ERROR(HttpStatus.INTERNAL_SERVER_ERROR); + INTEGRATION_ENCRYPTION_ERROR(HttpStatus.INTERNAL_SERVER_ERROR), + + /** The Jira OAuth authorization-code / refresh-token exchange with Atlassian failed. */ + JIRA_OAUTH_EXCHANGE_FAILED(HttpStatus.BAD_GATEWAY); private final HttpStatus status; diff --git a/src/main/java/com/kntro/reqsai/gateway/infrastructure/exception/IntegrationsInfrastructureExceptions.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/exception/IntegrationsInfrastructureExceptions.java index d1b57f92..63bd6ecb 100644 --- a/src/main/java/com/kntro/reqsai/gateway/infrastructure/exception/IntegrationsInfrastructureExceptions.java +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/exception/IntegrationsInfrastructureExceptions.java @@ -33,4 +33,10 @@ public static InfrastructureException encryptionError(String reason, Throwable c return new InfrastructureException(IntegrationsInfrastructureError.INTEGRATION_ENCRYPTION_ERROR, "Integration secret encryption failed: " + reason, cause); } + + /** The Jira OAuth token/refresh exchange with Atlassian failed. Never includes any token. */ + public static InfrastructureException jiraOauthExchangeFailed(String reason, Throwable cause) { + return new InfrastructureException(IntegrationsInfrastructureError.JIRA_OAUTH_EXCHANGE_FAILED, + "Jira OAuth token exchange failed: " + reason, cause); + } } diff --git a/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraOAuthProperties.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraOAuthProperties.java new file mode 100644 index 00000000..d60dfc04 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraOAuthProperties.java @@ -0,0 +1,41 @@ +package com.kntro.reqsai.gateway.infrastructure.jira; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.jspecify.annotations.Nullable; + +/** + * Jira OAuth 2.0 (3LO) app configuration bound from {@code reqsai.integrations.jira.oauth.*} (ADR-0022). + *

+ * All fields are OPTIONAL: when {@link #clientId}, {@link #clientSecret} or {@link #redirectUri} is + * blank the feature is considered not configured ({@link #configured()} is false) and the OAuth + * endpoints answer {@code JIRA_OAUTH_NOT_CONFIGURED} — the app still boots (unlike the required + * encryption key). Secrets are read from the environment and never logged. + * + * @param clientId the OAuth app client id (blank ⇒ not configured) + * @param clientSecret the OAuth app client secret (blank ⇒ not configured) + * @param redirectUri the registered callback URL (blank ⇒ not configured) + * @param stateSecret HMAC secret for signing the stateless {@code state} token; defaults to the + * encryption key material when unset + */ +@ConfigurationProperties(prefix = "reqsai.integrations.jira.oauth") +public record JiraOAuthProperties( + @Nullable String clientId, + @Nullable String clientSecret, + @Nullable String redirectUri, + @Nullable String stateSecret +) { + + /** True only when the three app credentials required to run the flow are all present. */ + public boolean configured() { + return notBlank(clientId) && notBlank(clientSecret) && notBlank(redirectUri); + } + + /** The HMAC signing secret, falling back to {@code client-secret} if no dedicated secret is set. */ + public String effectiveStateSecret() { + return notBlank(stateSecret) ? stateSecret : (clientSecret == null ? "" : clientSecret); + } + + private static boolean notBlank(@Nullable String value) { + return value != null && !value.isBlank(); + } +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index b1874b96..edb4ed26 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -33,6 +33,14 @@ reqsai: # Local-only default AES-256 key (base64 of bytes 0..31) so the app boots without a .env in dev. # Override with a real INTEGRATIONS_ENCRYPTION_KEY anywhere it matters. NEVER use this in prod. encryption-key: ${INTEGRATIONS_ENCRYPTION_KEY:AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=} + jira: + oauth: + # Dummy dev defaults so the OAuth beans wire and the authorize-url endpoint is exercisable + # locally. These are NOT real Atlassian credentials; register a real app and override via .env. + client-id: ${JIRA_OAUTH_CLIENT_ID:dev-client-id} + client-secret: ${JIRA_OAUTH_CLIENT_SECRET:dev-client-secret} + redirect-uri: ${JIRA_OAUTH_CALLBACK_URL:http://localhost:4200/integrations/jira/callback} + state-secret: ${JIRA_OAUTH_STATE_SECRET:AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=} logging: level: diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 77839dbe..0195f790 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -158,6 +158,17 @@ reqsai: # Base64-encoded 32-byte (AES-256) key used to encrypt third-party integration secrets at rest # (ADR-0023). Required for the integrations feature; keep it out of source control (.env / secret). encryption-key: ${INTEGRATIONS_ENCRYPTION_KEY:} + jira: + oauth: + # Jira OAuth 2.0 (3LO) app credentials (ADR-0022). OPTIONAL: when any of client-id/client-secret/ + # redirect-uri is blank the OAuth endpoints return JIRA_OAUTH_NOT_CONFIGURED and the app still + # boots (unlike the encryption key, which is required). Keep secrets out of source control (.env). + client-id: ${JIRA_OAUTH_CLIENT_ID:} + client-secret: ${JIRA_OAUTH_CLIENT_SECRET:} + redirect-uri: ${JIRA_OAUTH_CALLBACK_URL:} + # HMAC secret for signing the stateless OAuth `state` token. Defaults to the encryption key when + # unset so a single configured secret suffices; override with a dedicated value if desired. + state-secret: ${JIRA_OAUTH_STATE_SECRET:${INTEGRATIONS_ENCRYPTION_KEY:}} jwt: private-key-path: ${JWT_PRIVATE_KEY_PATH:classpath:certs/private_key.pem} private-key-pem: ${JWT_PRIVATE_KEY_PEM:} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index c10b0a97..e11c8fc0 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -25,6 +25,14 @@ reqsai: # Deterministic non-secret AES-256 key (base64 of bytes 0..31) so tests encrypt/decrypt integration # secrets without a .env. NEVER use this key outside tests/local dev. encryption-key: AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8= + jira: + oauth: + # Deterministic dummy OAuth config so the OAuth endpoints are configured (not disabled) in tests. + # No test hits real Atlassian — the JiraOAuthClient boundary is stubbed. + client-id: test-client-id + client-secret: test-client-secret + redirect-uri: http://localhost/integrations/jira/callback + state-secret: AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8= cors: allowed-origins: http://localhost:4200 allow-credentials: true From 60f7e44fae3ebc5d9c3770b7c5dc9248add42833 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Mon, 6 Jul 2026 20:25:21 -0500 Subject: [PATCH 30/72] feat(gateway): dual-mode jira client with oauth token refresh route the outbound base URL + auth header by credential type: API_TOKEN keeps basic auth against https://{site}/rest/api/3, OAUTH2 uses bearer auth against https://api.atlassian.com/ex/jira/{cloudId}/rest/api/3. ProviderCredentials now carries both shapes via apiToken()/oauth() factories. add JiraOAuthClient + JiraOAuthPort/adapter for code exchange, refresh and accessible-resources, and JiraOAuthTokenService which refreshes-before-call and persists rotated tokens. verify/listProjects/listIssueTypes/createIssue work unchanged for both modes. --- .../handler/ConnectJiraCommandHandler.java | 2 +- .../application/port/IntegrationProvider.java | 27 +++- .../application/port/JiraOAuthPort.java | 33 +++++ .../service/JiraOAuthTokenService.java | 58 ++++++++ .../service/ProviderCredentialsFactory.java | 20 ++- .../infrastructure/jira/JiraClient.java | 84 +++++++---- .../infrastructure/jira/JiraOAuthAdapter.java | 40 ++++++ .../infrastructure/jira/JiraOAuthClient.java | 132 ++++++++++++++++++ .../infrastructure/jira/JiraProvider.java | 27 +++- 9 files changed, 381 insertions(+), 42 deletions(-) create mode 100644 src/main/java/com/kntro/reqsai/gateway/application/port/JiraOAuthPort.java create mode 100644 src/main/java/com/kntro/reqsai/gateway/application/service/JiraOAuthTokenService.java create mode 100644 src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraOAuthAdapter.java create mode 100644 src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraOAuthClient.java diff --git a/src/main/java/com/kntro/reqsai/gateway/application/handler/ConnectJiraCommandHandler.java b/src/main/java/com/kntro/reqsai/gateway/application/handler/ConnectJiraCommandHandler.java index 040c3b96..92352cdf 100644 --- a/src/main/java/com/kntro/reqsai/gateway/application/handler/ConnectJiraCommandHandler.java +++ b/src/main/java/com/kntro/reqsai/gateway/application/handler/ConnectJiraCommandHandler.java @@ -36,7 +36,7 @@ public IntegrationConnection handle(ConnectJiraCommand command) { String siteUrl = IntegrationConnection.normalizeSiteUrl(command.siteUrl()); IntegrationProvider provider = providers.get(IntegrationProviderType.JIRA); // Verify the credential BEFORE persisting anything. Throws on auth/reachability failure. - provider.verify(new IntegrationProvider.ProviderCredentials(siteUrl, command.email(), command.apiToken())); + provider.verify(IntegrationProvider.ProviderCredentials.apiToken(siteUrl, command.email(), command.apiToken())); IntegrationConnection connection = new IntegrationConnection( command.organizationId(), IntegrationProviderType.JIRA, diff --git a/src/main/java/com/kntro/reqsai/gateway/application/port/IntegrationProvider.java b/src/main/java/com/kntro/reqsai/gateway/application/port/IntegrationProvider.java index 256c5ccd..ceafdf77 100644 --- a/src/main/java/com/kntro/reqsai/gateway/application/port/IntegrationProvider.java +++ b/src/main/java/com/kntro/reqsai/gateway/application/port/IntegrationProvider.java @@ -1,7 +1,9 @@ package com.kntro.reqsai.gateway.application.port; import com.kntro.reqsai.discovery.api.StoryView; +import com.kntro.reqsai.gateway.domain.model.CredentialType; import com.kntro.reqsai.gateway.domain.model.IntegrationProviderType; +import org.jspecify.annotations.Nullable; import java.util.List; @@ -31,8 +33,29 @@ public interface IntegrationProvider { /** Creates a tracker issue from a Reqs-AI story and returns its key + browse URL. */ PushedIssue pushStory(ProviderCredentials credentials, String projectKey, String issueTypeName, StoryView story); - /** Decrypted credentials for a single provider call (never persisted, never logged). */ - record ProviderCredentials(String siteUrl, String email, String apiToken) {} + /** + * Decrypted credentials for a single provider call (never persisted, never logged). Carries both + * credential shapes; {@link #credentialType} selects which is populated: + *

    + *
  • {@link CredentialType#API_TOKEN} — {@code siteUrl} + {@code email} + {@code apiToken}.
  • + *
  • {@link CredentialType#OAUTH2} — {@code siteUrl} (for browse URLs) + {@code cloudId} + + * {@code accessToken}. The access token is already fresh (refreshed by the caller if needed).
  • + *
+ */ + record ProviderCredentials(CredentialType credentialType, String siteUrl, + @Nullable String email, @Nullable String apiToken, + @Nullable String cloudId, @Nullable String accessToken) { + + /** API-token credentials (basic auth). */ + public static ProviderCredentials apiToken(String siteUrl, String email, String apiToken) { + return new ProviderCredentials(CredentialType.API_TOKEN, siteUrl, email, apiToken, null, null); + } + + /** OAuth 2.0 credentials (bearer auth); {@code accessToken} must already be valid. */ + public static ProviderCredentials oauth(String siteUrl, String cloudId, String accessToken) { + return new ProviderCredentials(CredentialType.OAUTH2, siteUrl, null, null, cloudId, accessToken); + } + } /** A remote project ({key,name}). */ record RemoteProject(String key, String name) {} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/port/JiraOAuthPort.java b/src/main/java/com/kntro/reqsai/gateway/application/port/JiraOAuthPort.java new file mode 100644 index 00000000..1db10596 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/port/JiraOAuthPort.java @@ -0,0 +1,33 @@ +package com.kntro.reqsai.gateway.application.port; + +import org.jspecify.annotations.Nullable; + +import java.util.List; + +/** + * Application seam for the Atlassian OAuth 2.0 (3LO) endpoints (ADR-0022): authorization-code exchange, + * refresh-token rotation, and accessible-resources discovery. The concrete HTTP lives in an + * infrastructure adapter over {@code JiraOAuthClient}; application code programs against this port so it + * never touches {@code infrastructure}. Tokens are opaque strings and are never logged by callers. + */ +public interface JiraOAuthPort { + + /** Exchanges an authorization {@code code} for the initial token set. */ + OAuthTokens exchangeCode(String code); + + /** Exchanges a {@code refreshToken} for a new (possibly rotated) token set. */ + OAuthTokens refresh(String refreshToken); + + /** Lists the Atlassian sites the {@code accessToken} can reach. */ + List accessibleResources(String accessToken); + + /** + * A token set from Atlassian. {@code refreshToken} may be null on a refresh if the app does not rotate + * refresh tokens; callers keep the prior refresh token in that case. {@code expiresInSeconds} is the + * access-token lifetime. + */ + record OAuthTokens(String accessToken, @Nullable String refreshToken, long expiresInSeconds, @Nullable String scope) {} + + /** An accessible Atlassian site: {@code cloudId} is used to build the OAuth Jira API base URL. */ + record Site(String cloudId, String url, String name) {} +} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/service/JiraOAuthTokenService.java b/src/main/java/com/kntro/reqsai/gateway/application/service/JiraOAuthTokenService.java new file mode 100644 index 00000000..1fe25f04 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/service/JiraOAuthTokenService.java @@ -0,0 +1,58 @@ +package com.kntro.reqsai.gateway.application.service; + +import com.kntro.reqsai.gateway.application.port.IntegrationConnectionRepository; +import com.kntro.reqsai.gateway.application.port.JiraOAuthPort; +import com.kntro.reqsai.gateway.application.port.JiraOAuthPort.OAuthTokens; +import com.kntro.reqsai.gateway.domain.model.IntegrationConnection; +import com.kntro.reqsai.gateway.infrastructure.exception.IntegrationsInfrastructureExceptions; +import com.kntro.reqsai.shared.domain.exception.InfrastructureException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.time.Instant; + +/** + * Ensures an OAuth 2.0 (3LO) {@link IntegrationConnection} has a usable, non-expired access token before a + * provider call (ADR-0022). If the cached access token is missing, expired, or within {@link #SKEW} of + * expiring, it refreshes via {@link JiraOAuthPort}, persists the rotated tokens (encrypted) + new expiry, + * and returns the fresh access token. A refresh failure surfaces as {@code JIRA_AUTH_FAILED}. Tokens are + * never logged. + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class JiraOAuthTokenService { + + /** Refresh a little before the token actually expires to avoid mid-call expiry races. */ + static final Duration SKEW = Duration.ofSeconds(60); + + private final JiraOAuthPort oauth; + private final IntegrationConnectionRepository connections; + + /** + * Returns a valid access token for {@code connection}, refreshing and persisting rotated tokens first + * if the cached one is stale. Assumes {@code connection} is an OAUTH2 connection. + */ + public String freshAccessToken(IntegrationConnection connection) { + Instant now = Instant.now(); + if (!connection.oauthAccessExpiredWithin(SKEW, now)) { + return connection.getOauthAccessToken(); + } + String refreshToken = connection.getOauthRefreshToken(); + if (refreshToken == null || refreshToken.isBlank()) { + throw IntegrationsInfrastructureExceptions.jiraAuthFailed(); + } + try { + OAuthTokens tokens = oauth.refresh(refreshToken); + Instant expiresAt = now.plusSeconds(tokens.expiresInSeconds()); + connection.applyRefreshedTokens(tokens.refreshToken(), tokens.accessToken(), expiresAt); + connections.save(connection); + return tokens.accessToken(); + } catch (InfrastructureException e) { + log.warn("OAuth refresh failed for connection {} [{}]", connection.getId(), e.error().code()); + throw IntegrationsInfrastructureExceptions.jiraAuthFailed(); + } + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/service/ProviderCredentialsFactory.java b/src/main/java/com/kntro/reqsai/gateway/application/service/ProviderCredentialsFactory.java index 6eabedb7..4c565422 100644 --- a/src/main/java/com/kntro/reqsai/gateway/application/service/ProviderCredentialsFactory.java +++ b/src/main/java/com/kntro/reqsai/gateway/application/service/ProviderCredentialsFactory.java @@ -1,18 +1,32 @@ package com.kntro.reqsai.gateway.application.service; import com.kntro.reqsai.gateway.application.port.IntegrationProvider.ProviderCredentials; +import com.kntro.reqsai.gateway.domain.model.CredentialType; import com.kntro.reqsai.gateway.domain.model.IntegrationConnection; +import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; /** * Builds provider {@link ProviderCredentials} from a persisted {@link IntegrationConnection}, decrypting - * the token (the {@code apiToken} getter returns the decrypted value via the JPA converter). Isolated so - * the decryption point is single and obvious; the result is short-lived and never logged. + * the secret (the getters return decrypted values via the JPA converter). Isolated so the decryption + * point is single and obvious; the result is short-lived and never logged. + *

+ * For {@link CredentialType#OAUTH2} connections it first ensures a fresh access token via + * {@link JiraOAuthTokenService} (refreshing + persisting rotated tokens if the cached one is stale), so + * the provider always receives a usable bearer token. */ @Component +@RequiredArgsConstructor public class ProviderCredentialsFactory { + private final JiraOAuthTokenService oauthTokens; + public ProviderCredentials from(IntegrationConnection connection) { - return new ProviderCredentials(connection.getSiteUrl(), connection.getEmail(), connection.getApiToken()); + if (connection.getCredentialType() == CredentialType.OAUTH2) { + String accessToken = oauthTokens.freshAccessToken(connection); + return ProviderCredentials.oauth(connection.getSiteUrl(), connection.getCloudId(), accessToken); + } + return ProviderCredentials.apiToken( + connection.getSiteUrl(), connection.getEmail(), connection.getApiToken()); } } diff --git a/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraClient.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraClient.java index d9d07ce1..f88de4c0 100644 --- a/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraClient.java +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraClient.java @@ -14,10 +14,16 @@ import java.util.Map; /** - * Outbound Jira Cloud REST v3 client (ADR-0022). Mirrors the {@code AssemblyAiAdapter} RestClient style: - * a per-call {@link RestClient}, typed Jackson response records, and HTTP-status → infrastructure - * exception mapping. Authentication is basic auth with the API token - * ({@code Authorization: Basic base64(email:token)}); the token is never logged nor placed in exceptions. + * Outbound Jira Cloud REST v3 client (ADR-0022), dual-mode across the two credential types: + *

    + *
  • API_TOKEN — base {@code https://{site}/rest/api/3} with basic auth + * ({@code Authorization: Basic base64(email:token)}).
  • + *
  • OAUTH2 — base {@code https://api.atlassian.com/ex/jira/{cloudId}/rest/api/3} + * with bearer auth ({@code Authorization: Bearer {access}}).
  • + *
+ * The base URL + {@code Authorization} header are supplied per call via a {@link JiraApiContext} built by + * {@link JiraProvider}, so the same call code serves both modes. Neither the token nor the header is ever + * logged or placed in exceptions. *
    *
  • 401/403 → {@code JIRA_AUTH_FAILED}
  • *
  • connect/timeout/5xx → {@code JIRA_UNREACHABLE}
  • @@ -28,13 +34,37 @@ @Slf4j public class JiraClient { + private static final String OAUTH_API_BASE = "https://api.atlassian.com/ex/jira/"; + private final RestClient restClient = RestClient.create(); - /** GET /rest/api/3/myself → the authenticated account's display name. */ - public String verify(String siteUrl, String email, String token) { + /** + * The per-call base URL + {@code Authorization} header for a Jira REST v3 call. The {@code browseBase} + * is the human site URL used to build a {@code /browse/{key}} link (same for both modes). + */ + public record JiraApiContext(String apiBase, String authHeader, String browseBase) { + + /** API-token context: {@code https://{site}/rest/api/3} + basic auth. */ + public static JiraApiContext apiToken(String siteUrl, String email, String token) { + return new JiraApiContext(siteUrl + "/rest/api/3", basic(email, token), siteUrl); + } + + /** OAuth context: {@code https://api.atlassian.com/ex/jira/{cloudId}/rest/api/3} + bearer auth. */ + public static JiraApiContext oauth(String cloudId, String accessToken, String browseBase) { + return new JiraApiContext(OAUTH_API_BASE + cloudId + "/rest/api/3", "Bearer " + accessToken, browseBase); + } + + private static String basic(String email, String token) { + String raw = email + ":" + token; + return "Basic " + Base64.getEncoder().encodeToString(raw.getBytes(StandardCharsets.UTF_8)); + } + } + + /** GET /myself → the authenticated account's display name. */ + public String verify(JiraApiContext ctx) { Myself me = exchange(() -> restClient.get() - .uri(siteUrl + "/rest/api/3/myself") - .header("Authorization", basic(email, token)) + .uri(ctx.apiBase() + "/myself") + .header("Authorization", ctx.authHeader()) .accept(MediaType.APPLICATION_JSON) .retrieve() .onStatus(HttpStatusCode::isError, (req, res) -> mapError(res.getStatusCode(), false)) @@ -42,11 +72,11 @@ public String verify(String siteUrl, String email, String token) { return me != null ? me.displayName() : ""; } - /** GET /rest/api/3/project/search → visible projects. */ - public List listProjects(String siteUrl, String email, String token) { + /** GET /project/search → visible projects. */ + public List listProjects(JiraApiContext ctx) { ProjectSearch search = exchange(() -> restClient.get() - .uri(siteUrl + "/rest/api/3/project/search?maxResults=100") - .header("Authorization", basic(email, token)) + .uri(ctx.apiBase() + "/project/search?maxResults=100") + .header("Authorization", ctx.authHeader()) .accept(MediaType.APPLICATION_JSON) .retrieve() .onStatus(HttpStatusCode::isError, (req, res) -> mapError(res.getStatusCode(), false)) @@ -54,11 +84,11 @@ public List listProjects(String siteUrl, String email, String token return search == null || search.values() == null ? List.of() : search.values(); } - /** GET /rest/api/3/issuetype/project?projectId= is key-based; we use the simpler global list. */ - public List listIssueTypes(String siteUrl, String email, String token, String projectKey) { + /** GET /issuetype → the global issue-type list (project param is accepted for parity, unused). */ + public List listIssueTypes(JiraApiContext ctx, String projectKey) { List types = exchange(() -> restClient.get() - .uri(siteUrl + "/rest/api/3/issuetype") - .header("Authorization", basic(email, token)) + .uri(ctx.apiBase() + "/issuetype") + .header("Authorization", ctx.authHeader()) .accept(MediaType.APPLICATION_JSON) .retrieve() .onStatus(HttpStatusCode::isError, (req, res) -> mapError(res.getStatusCode(), false)) @@ -66,18 +96,17 @@ public List listIssueTypes(String siteUrl, String email, String t return types == null ? List.of() : types; } - /** POST /rest/api/3/issue → the created issue's key + self URL. */ - public CreatedIssue createIssue(String siteUrl, String email, String token, - String projectKey, String issueTypeName, String summary, - Map descriptionAdf) { + /** POST /issue → the created issue's key + self URL. */ + public CreatedIssue createIssue(JiraApiContext ctx, String projectKey, String issueTypeName, + String summary, Map descriptionAdf) { Map fields = Map.of( "project", Map.of("key", projectKey), "issuetype", Map.of("name", issueTypeName), "summary", summary, "description", descriptionAdf); CreatedIssue created = exchange(() -> restClient.post() - .uri(siteUrl + "/rest/api/3/issue") - .header("Authorization", basic(email, token)) + .uri(ctx.apiBase() + "/issue") + .header("Authorization", ctx.authHeader()) .contentType(MediaType.APPLICATION_JSON) .accept(MediaType.APPLICATION_JSON) .body(Map.of("fields", fields)) @@ -90,18 +119,13 @@ public CreatedIssue createIssue(String siteUrl, String email, String token, return created; } - /** Browse URL for a created issue. */ - public String browseUrl(String siteUrl, String issueKey) { - return siteUrl + "/browse/" + issueKey; + /** Browse URL for a created issue (uses the human site URL, not the OAuth API base). */ + public String browseUrl(String browseBase, String issueKey) { + return browseBase + "/browse/" + issueKey; } // Helpers - private static String basic(String email, String token) { - String raw = email + ":" + token; - return "Basic " + Base64.getEncoder().encodeToString(raw.getBytes(StandardCharsets.UTF_8)); - } - /** * Maps an error status inside the RestClient exchange. {@code onCreate} selects the 400 → push-failed * mapping; otherwise 400 falls through to unreachable. Throwing here aborts the call with a mapped, diff --git a/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraOAuthAdapter.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraOAuthAdapter.java new file mode 100644 index 00000000..d51ba2e1 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraOAuthAdapter.java @@ -0,0 +1,40 @@ +package com.kntro.reqsai.gateway.infrastructure.jira; + +import com.kntro.reqsai.gateway.application.port.JiraOAuthPort; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * Adapts the {@link JiraOAuthPort} application port to the {@link JiraOAuthClient} HTTP client (ADR-0022), + * translating the client's Jackson records into the port's value records. Keeps application code off + * infrastructure. + */ +@Component +@RequiredArgsConstructor +public class JiraOAuthAdapter implements JiraOAuthPort { + + private final JiraOAuthClient client; + + @Override + public OAuthTokens exchangeCode(String code) { + return toTokens(client.exchangeCode(code)); + } + + @Override + public OAuthTokens refresh(String refreshToken) { + return toTokens(client.refresh(refreshToken)); + } + + @Override + public List accessibleResources(String accessToken) { + return client.accessibleResources(accessToken).stream() + .map(r -> new Site(r.id(), r.url(), r.name())) + .toList(); + } + + private static OAuthTokens toTokens(JiraOAuthClient.OAuthTokens t) { + return new OAuthTokens(t.accessToken(), t.refreshToken(), t.expiresIn(), t.scope()); + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraOAuthClient.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraOAuthClient.java new file mode 100644 index 00000000..574ca491 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraOAuthClient.java @@ -0,0 +1,132 @@ +package com.kntro.reqsai.gateway.infrastructure.jira; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.kntro.reqsai.gateway.infrastructure.exception.IntegrationsInfrastructureExceptions; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +import java.util.List; +import java.util.Map; + +/** + * Outbound Atlassian OAuth 2.0 (3LO) client (ADR-0022): authorization-code exchange, refresh-token + * rotation, and accessible-resources discovery. Mirrors the {@link JiraClient} RestClient style (per-call + * client, typed records, status → infrastructure exception). + *
      + *
    • Token exchange / refresh: {@code POST https://auth.atlassian.com/oauth/token} (JSON).
    • + *
    • Sites: {@code GET https://api.atlassian.com/oauth/token/accessible-resources} (Bearer access).
    • + *
    + * Any non-2xx on token exchange/refresh maps to {@code JIRA_OAUTH_EXCHANGE_FAILED}; a 401/403 on + * accessible-resources maps to {@code JIRA_AUTH_FAILED}. Tokens are never logged nor put in exceptions. + */ +@Component +@Slf4j +public class JiraOAuthClient { + + private static final String TOKEN_URL = "https://auth.atlassian.com/oauth/token"; + private static final String RESOURCES_URL = "https://api.atlassian.com/oauth/token/accessible-resources"; + + private final RestClient restClient = RestClient.create(); + private final JiraOAuthProperties props; + + public JiraOAuthClient(JiraOAuthProperties props) { + this.props = props; + } + + /** Exchanges an authorization {@code code} for the initial token set. */ + public OAuthTokens exchangeCode(String code) { + Map body = Map.of( + "grant_type", "authorization_code", + "client_id", nn(props.clientId()), + "client_secret", nn(props.clientSecret()), + "code", code, + "redirect_uri", nn(props.redirectUri())); + return postToken(body, "exchangeCode"); + } + + /** Exchanges a {@code refreshToken} for a new (rotated) token set. */ + public OAuthTokens refresh(String refreshToken) { + Map body = Map.of( + "grant_type", "refresh_token", + "client_id", nn(props.clientId()), + "client_secret", nn(props.clientSecret()), + "refresh_token", refreshToken); + return postToken(body, "refresh"); + } + + /** Lists the Atlassian sites the access token can reach ({cloudId, url, name}). */ + public List accessibleResources(String accessToken) { + try { + List sites = restClient.get() + .uri(RESOURCES_URL) + .header("Authorization", "Bearer " + accessToken) + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .onStatus(HttpStatusCode::isError, (req, res) -> { + if (res.getStatusCode().value() == 401 || res.getStatusCode().value() == 403) { + throw IntegrationsInfrastructureExceptions.jiraAuthFailed(); + } + throw IntegrationsInfrastructureExceptions.jiraOauthExchangeFailed( + "accessible-resources HTTP " + res.getStatusCode().value(), null); + }) + .body(RESOURCE_LIST); + return sites == null ? List.of() : sites; + } catch (com.kntro.reqsai.shared.domain.exception.InfrastructureException mapped) { + throw mapped; + } catch (Exception e) { + log.warn("Jira accessible-resources failed: {}", e.getMessage()); + throw IntegrationsInfrastructureExceptions.jiraOauthExchangeFailed("accessible-resources", e); + } + } + + private OAuthTokens postToken(Map body, String op) { + try { + OAuthTokens tokens = restClient.post() + .uri(TOKEN_URL) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .body(body) + .retrieve() + .onStatus(HttpStatusCode::isError, (req, res) -> { + throw IntegrationsInfrastructureExceptions.jiraOauthExchangeFailed( + op + " HTTP " + res.getStatusCode().value(), null); + }) + .body(OAuthTokens.class); + if (tokens == null || tokens.accessToken() == null) { + throw IntegrationsInfrastructureExceptions.jiraOauthExchangeFailed(op + ": empty token response", null); + } + return tokens; + } catch (com.kntro.reqsai.shared.domain.exception.InfrastructureException mapped) { + throw mapped; + } catch (Exception e) { + log.warn("Jira OAuth {} failed: {}", op, e.getMessage()); + throw IntegrationsInfrastructureExceptions.jiraOauthExchangeFailed(op, e); + } + } + + private static String nn(String value) { + return value == null ? "" : value; + } + + private static final org.springframework.core.ParameterizedTypeReference> RESOURCE_LIST = + new org.springframework.core.ParameterizedTypeReference<>() {}; + + /** + * Atlassian token response. {@code refreshToken} is present when {@code offline_access} was requested; + * on a rotating-refresh-token app it is a NEW value on every refresh (persist it). + */ + @JsonIgnoreProperties(ignoreUnknown = true) + public record OAuthTokens( + @JsonProperty("access_token") String accessToken, + @JsonProperty("refresh_token") String refreshToken, + @JsonProperty("expires_in") long expiresIn, + @JsonProperty("scope") String scope) {} + + /** One accessible Atlassian site: {@code id} is the cloud id used in the OAuth API base URL. */ + @JsonIgnoreProperties(ignoreUnknown = true) + public record AccessibleResource(String id, String url, String name) {} +} diff --git a/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraProvider.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraProvider.java index 96ab8085..d88f8d7e 100644 --- a/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraProvider.java +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraProvider.java @@ -2,7 +2,9 @@ import com.kntro.reqsai.discovery.api.StoryView; import com.kntro.reqsai.gateway.application.port.IntegrationProvider; +import com.kntro.reqsai.gateway.domain.model.CredentialType; import com.kntro.reqsai.gateway.domain.model.IntegrationProviderType; +import com.kntro.reqsai.gateway.infrastructure.jira.JiraClient.JiraApiContext; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -12,6 +14,11 @@ /** * Jira Cloud implementation of {@link IntegrationProvider} (ADR-0022). Translates provider-neutral calls * into {@link JiraClient} REST calls and renders the story description as ADF via {@link JiraAdfBuilder}. + *

    + * Dual-mode: {@link #contextFor(ProviderCredentials)} picks the base URL + {@code Authorization} header + * from the credential type — basic auth against {@code https://{site}/rest/api/3} for API tokens, bearer + * auth against {@code https://api.atlassian.com/ex/jira/{cloudId}/rest/api/3} for OAuth. OAuth access + * tokens arrive already fresh (refreshed upstream by {@code ProviderCredentialsFactory}). */ @Component @RequiredArgsConstructor @@ -26,28 +33,36 @@ public IntegrationProviderType type() { @Override public String verify(ProviderCredentials c) { - return jira.verify(c.siteUrl(), c.email(), c.apiToken()); + return jira.verify(contextFor(c)); } @Override public List listProjects(ProviderCredentials c) { - return jira.listProjects(c.siteUrl(), c.email(), c.apiToken()).stream() + return jira.listProjects(contextFor(c)).stream() .map(p -> new RemoteProject(p.key(), p.name())) .toList(); } @Override public List listIssueTypes(ProviderCredentials c, String projectKey) { - return jira.listIssueTypes(c.siteUrl(), c.email(), c.apiToken(), projectKey).stream() + return jira.listIssueTypes(contextFor(c), projectKey).stream() .map(t -> new RemoteIssueType(t.id(), t.name())) .toList(); } @Override public PushedIssue pushStory(ProviderCredentials c, String projectKey, String issueTypeName, StoryView story) { + JiraApiContext ctx = contextFor(c); Map description = JiraAdfBuilder.buildDescription(story); - JiraClient.CreatedIssue created = jira.createIssue( - c.siteUrl(), c.email(), c.apiToken(), projectKey, issueTypeName, story.title(), description); - return new PushedIssue(created.key(), jira.browseUrl(c.siteUrl(), created.key())); + JiraClient.CreatedIssue created = jira.createIssue(ctx, projectKey, issueTypeName, story.title(), description); + return new PushedIssue(created.key(), jira.browseUrl(ctx.browseBase(), created.key())); + } + + /** Builds the base-URL + auth context for the credential's mode. */ + private static JiraApiContext contextFor(ProviderCredentials c) { + if (c.credentialType() == CredentialType.OAUTH2) { + return JiraApiContext.oauth(c.cloudId(), c.accessToken(), c.siteUrl()); + } + return JiraApiContext.apiToken(c.siteUrl(), c.email(), c.apiToken()); } } From 06998e5721620a32c7ec6a409d2ea6bfe8a434d0 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Mon, 6 Jul 2026 20:31:43 -0500 Subject: [PATCH 31/72] feat(gateway): add jira oauth authorize-url and callback endpoints GET /organizations/{orgId}/integrations/jira/oauth/authorize-url returns the Atlassian authorize url plus a stateless HMAC-signed state (org+user+expiry+nonce). POST /organizations/{orgId}/integrations/jira/oauth/callback validates state, exchanges the code, discovers accessible sites and either saves an encrypted OAUTH2 connection (cloudId given or single site) or returns the site list without saving (multi-site). enforces one-active-connection (409) and rejects unconfigured oauth (501). move JiraOAuthProperties into application.config so application code stays off infrastructure. --- .../command/JiraOAuthCallbackCommand.java | 19 +++ .../config}/JiraOAuthProperties.java | 2 +- .../JiraOAuthCallbackCommandHandler.java | 93 +++++++++++++++ .../result/JiraOAuthCallbackResult.java | 32 +++++ .../service/JiraOAuthAuthorizeService.java | 53 +++++++++ .../service/JiraOAuthStateService.java | 112 ++++++++++++++++++ .../infrastructure/jira/JiraOAuthClient.java | 1 + ...OrganizationIntegrationControllerImpl.java | 38 ++++++ .../dto/request/JiraOAuthCallbackRequest.java | 22 ++++ .../JiraOAuthAuthorizeUrlResponse.java | 10 ++ .../dto/response/JiraOAuthSiteResponse.java | 11 ++ .../dto/response/JiraOAuthSitesResponse.java | 15 +++ .../response/IntegrationResponseMapper.java | 6 + .../OrganizationIntegrationController.java | 39 ++++++ 14 files changed, 452 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/kntro/reqsai/gateway/application/command/JiraOAuthCallbackCommand.java rename src/main/java/com/kntro/reqsai/gateway/{infrastructure/jira => application/config}/JiraOAuthProperties.java (97%) create mode 100644 src/main/java/com/kntro/reqsai/gateway/application/handler/JiraOAuthCallbackCommandHandler.java create mode 100644 src/main/java/com/kntro/reqsai/gateway/application/result/JiraOAuthCallbackResult.java create mode 100644 src/main/java/com/kntro/reqsai/gateway/application/service/JiraOAuthAuthorizeService.java create mode 100644 src/main/java/com/kntro/reqsai/gateway/application/service/JiraOAuthStateService.java create mode 100644 src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/request/JiraOAuthCallbackRequest.java create mode 100644 src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/JiraOAuthAuthorizeUrlResponse.java create mode 100644 src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/JiraOAuthSiteResponse.java create mode 100644 src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/JiraOAuthSitesResponse.java diff --git a/src/main/java/com/kntro/reqsai/gateway/application/command/JiraOAuthCallbackCommand.java b/src/main/java/com/kntro/reqsai/gateway/application/command/JiraOAuthCallbackCommand.java new file mode 100644 index 00000000..90400f73 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/command/JiraOAuthCallbackCommand.java @@ -0,0 +1,19 @@ +package com.kntro.reqsai.gateway.application.command; + +import org.jspecify.annotations.Nullable; + +import java.util.UUID; + +/** + * Completes the Jira OAuth 2.0 (3LO) flow at the organization level (ADR-0022): validates {@code state}, + * exchanges {@code code}, discovers accessible sites and — if a site is chosen ({@code cloudId} given or + * exactly one available) — persists an OAUTH2 connection. When multiple sites exist and {@code cloudId} + * is null the handler returns the site list WITHOUT saving. + */ +public record JiraOAuthCallbackCommand( + UUID organizationId, + String code, + String state, + @Nullable String cloudId, + UUID requestedBy +) {} diff --git a/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraOAuthProperties.java b/src/main/java/com/kntro/reqsai/gateway/application/config/JiraOAuthProperties.java similarity index 97% rename from src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraOAuthProperties.java rename to src/main/java/com/kntro/reqsai/gateway/application/config/JiraOAuthProperties.java index d60dfc04..99b0c947 100644 --- a/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraOAuthProperties.java +++ b/src/main/java/com/kntro/reqsai/gateway/application/config/JiraOAuthProperties.java @@ -1,4 +1,4 @@ -package com.kntro.reqsai.gateway.infrastructure.jira; +package com.kntro.reqsai.gateway.application.config; import org.springframework.boot.context.properties.ConfigurationProperties; import org.jspecify.annotations.Nullable; diff --git a/src/main/java/com/kntro/reqsai/gateway/application/handler/JiraOAuthCallbackCommandHandler.java b/src/main/java/com/kntro/reqsai/gateway/application/handler/JiraOAuthCallbackCommandHandler.java new file mode 100644 index 00000000..4b9c9fec --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/handler/JiraOAuthCallbackCommandHandler.java @@ -0,0 +1,93 @@ +package com.kntro.reqsai.gateway.application.handler; + +import com.kntro.reqsai.gateway.application.command.JiraOAuthCallbackCommand; +import com.kntro.reqsai.gateway.application.port.IntegrationConnectionRepository; +import com.kntro.reqsai.gateway.application.port.JiraOAuthPort; +import com.kntro.reqsai.gateway.application.port.JiraOAuthPort.OAuthTokens; +import com.kntro.reqsai.gateway.application.port.JiraOAuthPort.Site; +import com.kntro.reqsai.gateway.application.result.JiraOAuthCallbackResult; +import com.kntro.reqsai.gateway.application.service.JiraOAuthStateService; +import com.kntro.reqsai.gateway.domain.exception.IntegrationsExceptions; +import com.kntro.reqsai.gateway.domain.model.ConnectionStatus; +import com.kntro.reqsai.gateway.domain.model.IntegrationConnection; +import com.kntro.reqsai.gateway.domain.model.IntegrationProviderType; +import com.kntro.reqsai.gateway.application.config.JiraOAuthProperties; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.List; + +/** + * Completes the Jira OAuth 2.0 (3LO) org-level flow (ADR-0022): + *

      + *
    1. reject if OAuth is not configured ({@code JIRA_OAUTH_NOT_CONFIGURED});
    2. + *
    3. validate the signed {@code state} against this org+user ({@code JIRA_OAUTH_STATE_INVALID});
    4. + *
    5. exchange the authorization {@code code} for tokens and discover accessible sites;
    6. + *
    7. if a {@code cloudId} is given use it, else if exactly one site auto-select it, else return the + * site list WITHOUT saving (the frontend re-POSTs with a chosen {@code cloudId});
    8. + *
    9. on selection, enforce one active connection per org ({@code INTEGRATION_ALREADY_CONNECTED}) and + * persist an encrypted OAUTH2 connection.
    10. + *
    + */ +@Component +@RequiredArgsConstructor +public class JiraOAuthCallbackCommandHandler { + + private final JiraOAuthProperties props; + private final JiraOAuthStateService stateService; + private final JiraOAuthPort oauth; + private final IntegrationConnectionRepository connections; + + @Transactional + public JiraOAuthCallbackResult handle(JiraOAuthCallbackCommand command) { + if (!props.configured()) { + throw IntegrationsExceptions.oauthNotConfigured(); + } + stateService.verify(command.state(), command.organizationId(), command.requestedBy()); + + OAuthTokens tokens = oauth.exchangeCode(command.code()); + List sites = oauth.accessibleResources(tokens.accessToken()); + if (sites.isEmpty()) { + // No Jira site is reachable with the granted consent — treat as an auth failure. + throw IntegrationsExceptions.oauthStateInvalid("no accessible Jira sites for the granted consent"); + } + + Site chosen = selectSite(sites, command.cloudId()); + if (chosen == null) { + // Multiple sites and no cloudId yet: let the frontend choose. Nothing is persisted. + return JiraOAuthCallbackResult.needsSiteSelection(sites); + } + + if (connections.existsByOrganizationIdAndProviderAndStatusNot( + command.organizationId(), IntegrationProviderType.JIRA, ConnectionStatus.DISCONNECTED)) { + throw IntegrationsExceptions.alreadyConnected( + command.organizationId(), IntegrationProviderType.JIRA.name()); + } + + Instant now = Instant.now(); + Instant accessExpiresAt = now.plusSeconds(tokens.expiresInSeconds()); + IntegrationConnection connection = IntegrationConnection.oauth( + command.organizationId(), IntegrationProviderType.JIRA, + chosen.url(), chosen.cloudId(), tokens.refreshToken(), + tokens.accessToken(), accessExpiresAt, now); + return JiraOAuthCallbackResult.saved(connections.save(connection)); + } + + /** + * Chooses the site to connect: the one matching {@code requestedCloudId} if given (or throws if it is + * not among the accessible sites), otherwise the sole site when exactly one exists, otherwise null to + * signal that the caller must pick. + */ + private static Site selectSite(List sites, String requestedCloudId) { + if (requestedCloudId != null && !requestedCloudId.isBlank()) { + return sites.stream() + .filter(s -> s.cloudId().equals(requestedCloudId)) + .findFirst() + .orElseThrow(() -> IntegrationsExceptions.oauthStateInvalid( + "chosen cloudId is not among the accessible sites")); + } + return sites.size() == 1 ? sites.get(0) : null; + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/result/JiraOAuthCallbackResult.java b/src/main/java/com/kntro/reqsai/gateway/application/result/JiraOAuthCallbackResult.java new file mode 100644 index 00000000..88d75cba --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/result/JiraOAuthCallbackResult.java @@ -0,0 +1,32 @@ +package com.kntro.reqsai.gateway.application.result; + +import com.kntro.reqsai.gateway.application.port.JiraOAuthPort.Site; +import com.kntro.reqsai.gateway.domain.model.IntegrationConnection; +import org.jspecify.annotations.Nullable; + +import java.util.List; + +/** + * Outcome of the Jira OAuth callback (ADR-0022): either a saved {@link #connection} (a site was chosen or + * auto-selected), or a non-empty list of {@link #sites} to choose from (multiple sites, no {@code cloudId} + * yet) — in which case nothing was persisted and the frontend re-POSTs with a chosen {@code cloudId}. + * Exactly one of the two is non-null. + */ +public record JiraOAuthCallbackResult( + @Nullable IntegrationConnection connection, + @Nullable List sites +) { + + public static JiraOAuthCallbackResult saved(IntegrationConnection connection) { + return new JiraOAuthCallbackResult(connection, null); + } + + public static JiraOAuthCallbackResult needsSiteSelection(List sites) { + return new JiraOAuthCallbackResult(null, sites); + } + + /** True when a connection was saved; false when the caller must pick a site. */ + public boolean isSaved() { + return connection != null; + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/service/JiraOAuthAuthorizeService.java b/src/main/java/com/kntro/reqsai/gateway/application/service/JiraOAuthAuthorizeService.java new file mode 100644 index 00000000..4aa40ef7 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/service/JiraOAuthAuthorizeService.java @@ -0,0 +1,53 @@ +package com.kntro.reqsai.gateway.application.service; + +import com.kntro.reqsai.gateway.domain.exception.IntegrationsExceptions; +import com.kntro.reqsai.gateway.application.config.JiraOAuthProperties; +import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; + +import java.util.UUID; + +/** + * Builds the Atlassian authorize URL for the OAuth 2.0 (3LO) flow (ADR-0022): + * {@code https://auth.atlassian.com/authorize?audience=api.atlassian.com&client_id=...&scope=...& + * redirect_uri=...&state=...&response_type=code&prompt=consent}. The {@code state} is a stateless signed + * token from {@link JiraOAuthStateService}. When OAuth is not configured it raises + * {@code JIRA_OAUTH_NOT_CONFIGURED} so the UI can disable the button. + */ +@Component +public class JiraOAuthAuthorizeService { + + private static final String AUTHORIZE_URL = "https://auth.atlassian.com/authorize"; + /** offline_access yields a refresh token; read:me + jira scopes cover verify/list/create. */ + private static final String SCOPES = "read:jira-work write:jira-work read:jira-user offline_access read:me"; + + private final JiraOAuthProperties props; + private final JiraOAuthStateService stateService; + + public JiraOAuthAuthorizeService(JiraOAuthProperties props, JiraOAuthStateService stateService) { + this.props = props; + this.stateService = stateService; + } + + /** Builds the authorize URL + signed state for {@code orgId}/{@code userId}. */ + public AuthorizeUrl build(UUID orgId, UUID userId) { + if (!props.configured()) { + throw IntegrationsExceptions.oauthNotConfigured(); + } + String state = stateService.issue(orgId, userId); + String url = UriComponentsBuilder.fromUriString(AUTHORIZE_URL) + .queryParam("audience", "api.atlassian.com") + .queryParam("client_id", props.clientId()) + .queryParam("scope", SCOPES) + .queryParam("redirect_uri", props.redirectUri()) + .queryParam("state", state) + .queryParam("response_type", "code") + .queryParam("prompt", "consent") + .encode() + .toUriString(); + return new AuthorizeUrl(url, state); + } + + /** The built authorize URL and the signed state embedded in it. */ + public record AuthorizeUrl(String url, String state) {} +} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/service/JiraOAuthStateService.java b/src/main/java/com/kntro/reqsai/gateway/application/service/JiraOAuthStateService.java new file mode 100644 index 00000000..37798292 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/service/JiraOAuthStateService.java @@ -0,0 +1,112 @@ +package com.kntro.reqsai.gateway.application.service; + +import com.kntro.reqsai.gateway.domain.exception.IntegrationsExceptions; +import com.kntro.reqsai.gateway.application.config.JiraOAuthProperties; +import org.springframework.stereotype.Component; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.time.Duration; +import java.time.Instant; +import java.util.Base64; +import java.util.UUID; + +/** + * Signs and verifies the STATELESS OAuth {@code state} token (ADR-0022). The token binds the CSRF state + * to the initiating {@code orgId} + {@code userId} with a short expiry and a random nonce, so nothing has + * to be stored server-side and it survives the browser redirect. Format: + *
    {@code base64url(orgId|userId|expiryEpochSeconds|nonce) + "." + base64url(HMAC-SHA256(payload))}
    + * The HMAC key is {@code reqsai.integrations.jira.oauth.state-secret} (falling back to the client secret). + * Verification checks the signature (constant-time), the expiry, and that the org/user match the caller; + * any failure raises {@code JIRA_OAUTH_STATE_INVALID}. + */ +@Component +public class JiraOAuthStateService { + + /** How long an issued state token stays valid — long enough to complete consent, short enough to bound replay. */ + static final Duration TTL = Duration.ofMinutes(15); + + private static final String HMAC_ALG = "HmacSHA256"; + private static final Base64.Encoder B64 = Base64.getUrlEncoder().withoutPadding(); + private static final Base64.Decoder B64D = Base64.getUrlDecoder(); + + private final JiraOAuthProperties props; + private final SecureRandom random = new SecureRandom(); + + public JiraOAuthStateService(JiraOAuthProperties props) { + this.props = props; + } + + /** Issues a signed state token for {@code orgId} + {@code userId}, valid for {@link #TTL}. */ + public String issue(UUID orgId, UUID userId) { + long expiry = Instant.now().plus(TTL).getEpochSecond(); + String nonce = UUID.randomUUID().toString().replace("-", ""); + String payload = "%s|%s|%d|%s".formatted(orgId, userId, expiry, nonce); + String encodedPayload = B64.encodeToString(payload.getBytes(StandardCharsets.UTF_8)); + return encodedPayload + "." + B64.encodeToString(hmac(encodedPayload)); + } + + /** + * Verifies {@code state} was issued for this {@code orgId} + {@code userId}, is unexpired, and has a + * valid signature. Throws {@code JIRA_OAUTH_STATE_INVALID} on any failure. + */ + public void verify(String state, UUID orgId, UUID userId) { + if (state == null || state.isBlank()) { + throw IntegrationsExceptions.oauthStateInvalid("missing state"); + } + int dot = state.indexOf('.'); + if (dot <= 0 || dot == state.length() - 1) { + throw IntegrationsExceptions.oauthStateInvalid("malformed state"); + } + String encodedPayload = state.substring(0, dot); + String signature = state.substring(dot + 1); + + byte[] expected = hmac(encodedPayload); + byte[] provided; + try { + provided = B64D.decode(signature); + } catch (IllegalArgumentException e) { + throw IntegrationsExceptions.oauthStateInvalid("bad signature encoding"); + } + if (!MessageDigest.isEqual(expected, provided)) { + throw IntegrationsExceptions.oauthStateInvalid("signature mismatch"); + } + + String payload; + try { + payload = new String(B64D.decode(encodedPayload), StandardCharsets.UTF_8); + } catch (IllegalArgumentException e) { + throw IntegrationsExceptions.oauthStateInvalid("bad payload encoding"); + } + String[] parts = payload.split("\\|"); + if (parts.length != 4) { + throw IntegrationsExceptions.oauthStateInvalid("malformed payload"); + } + if (!parts[0].equals(orgId.toString()) || !parts[1].equals(userId.toString())) { + throw IntegrationsExceptions.oauthStateInvalid("org/user mismatch"); + } + long expiry; + try { + expiry = Long.parseLong(parts[2]); + } catch (NumberFormatException e) { + throw IntegrationsExceptions.oauthStateInvalid("bad expiry"); + } + if (Instant.now().getEpochSecond() > expiry) { + throw IntegrationsExceptions.oauthStateInvalid("expired"); + } + } + + private byte[] hmac(String data) { + try { + Mac mac = Mac.getInstance(HMAC_ALG); + mac.init(new SecretKeySpec(props.effectiveStateSecret().getBytes(StandardCharsets.UTF_8), HMAC_ALG)); + return mac.doFinal(data.getBytes(StandardCharsets.UTF_8)); + } catch (Exception e) { + // A misconfigured/empty secret is a server config problem, not a client one. + throw new IllegalStateException("OAuth state HMAC failed", e); + } + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraOAuthClient.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraOAuthClient.java index 574ca491..23cf9a86 100644 --- a/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraOAuthClient.java +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraOAuthClient.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; +import com.kntro.reqsai.gateway.application.config.JiraOAuthProperties; import com.kntro.reqsai.gateway.infrastructure.exception.IntegrationsInfrastructureExceptions; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatusCode; diff --git a/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/controllers/OrganizationIntegrationControllerImpl.java b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/controllers/OrganizationIntegrationControllerImpl.java index e98505d5..e19ee76c 100644 --- a/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/controllers/OrganizationIntegrationControllerImpl.java +++ b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/controllers/OrganizationIntegrationControllerImpl.java @@ -2,20 +2,28 @@ import com.kntro.reqsai.gateway.application.handler.ConnectJiraCommandHandler; import com.kntro.reqsai.gateway.application.handler.DeleteConnectionCommandHandler; +import com.kntro.reqsai.gateway.application.handler.JiraOAuthCallbackCommandHandler; import com.kntro.reqsai.gateway.application.handler.ListConnectionsQueryHandler; import com.kntro.reqsai.gateway.application.handler.ListJiraIssueTypesQueryHandler; import com.kntro.reqsai.gateway.application.handler.ListJiraProjectsQueryHandler; import com.kntro.reqsai.gateway.application.handler.TestConnectionQueryHandler; import com.kntro.reqsai.gateway.application.command.DeleteConnectionCommand; +import com.kntro.reqsai.gateway.application.command.JiraOAuthCallbackCommand; import com.kntro.reqsai.gateway.application.query.ListConnectionsQuery; import com.kntro.reqsai.gateway.application.query.ListJiraIssueTypesQuery; import com.kntro.reqsai.gateway.application.query.ListJiraProjectsQuery; import com.kntro.reqsai.gateway.application.query.TestConnectionQuery; +import com.kntro.reqsai.gateway.application.result.JiraOAuthCallbackResult; +import com.kntro.reqsai.gateway.application.service.JiraOAuthAuthorizeService; +import com.kntro.reqsai.gateway.application.service.JiraOAuthAuthorizeService.AuthorizeUrl; import com.kntro.reqsai.gateway.domain.model.IntegrationConnection; import com.kntro.reqsai.gateway.interfaces.rest.dto.request.ConnectJiraRequest; +import com.kntro.reqsai.gateway.interfaces.rest.dto.request.JiraOAuthCallbackRequest; import com.kntro.reqsai.gateway.interfaces.rest.dto.response.ConnectionTestResponse; import com.kntro.reqsai.gateway.interfaces.rest.dto.response.IntegrationConnectionResponse; import com.kntro.reqsai.gateway.interfaces.rest.dto.response.JiraIssueTypeResponse; +import com.kntro.reqsai.gateway.interfaces.rest.dto.response.JiraOAuthAuthorizeUrlResponse; +import com.kntro.reqsai.gateway.interfaces.rest.dto.response.JiraOAuthSitesResponse; import com.kntro.reqsai.gateway.interfaces.rest.dto.response.JiraProjectResponse; import com.kntro.reqsai.gateway.interfaces.rest.mappers.request.IntegrationRequestMapper; import com.kntro.reqsai.gateway.interfaces.rest.mappers.response.IntegrationResponseMapper; @@ -45,6 +53,8 @@ public class OrganizationIntegrationControllerImpl implements OrganizationIntegr private final DeleteConnectionCommandHandler deleteConnection; private final ListJiraProjectsQueryHandler listJiraProjects; private final ListJiraIssueTypesQueryHandler listJiraIssueTypes; + private final JiraOAuthAuthorizeService jiraOAuthAuthorize; + private final JiraOAuthCallbackCommandHandler jiraOAuthCallback; @Override @PreAuthorize("@authz.orgOwnerOrAdmin(#orgId, authentication)") @@ -69,6 +79,34 @@ public ResponseEntity connectJira( return ResponseEntity.created(location).body(IntegrationResponseMapper.toResponse(connection)); } + @Override + @PreAuthorize("@authz.orgOwnerOrAdmin(#orgId, authentication)") + public ResponseEntity jiraOAuthAuthorizeUrl(UUID orgId, Authentication authentication) { + UUID requestedBy = UUID.fromString(authentication.getName()); + AuthorizeUrl authorizeUrl = jiraOAuthAuthorize.build(orgId, requestedBy); + return ResponseEntity.ok(new JiraOAuthAuthorizeUrlResponse(authorizeUrl.url(), authorizeUrl.state())); + } + + @Override + @PreAuthorize("@authz.orgOwnerOrAdmin(#orgId, authentication)") + public ResponseEntity jiraOAuthCallback( + UUID orgId, JiraOAuthCallbackRequest request, Authentication authentication) { + UUID requestedBy = UUID.fromString(authentication.getName()); + JiraOAuthCallbackResult result = jiraOAuthCallback.handle(new JiraOAuthCallbackCommand( + orgId, request.code(), request.state(), request.cloudId(), requestedBy)); + if (result.isSaved()) { + IntegrationConnection connection = result.connection(); + URI location = ServletUriComponentsBuilder.fromCurrentRequest() + .replacePath("/api/organizations/{orgId}/integrations/{id}") + .buildAndExpand(orgId, connection.getId()) + .toUri(); + return ResponseEntity.created(location).body(IntegrationResponseMapper.toResponse(connection)); + } + JiraOAuthSitesResponse sites = new JiraOAuthSitesResponse( + result.sites().stream().map(IntegrationResponseMapper::toResponse).toList()); + return ResponseEntity.ok(sites); + } + @Override @PreAuthorize("@authz.orgOwnerOrAdmin(#orgId, authentication)") public ResponseEntity testConnection(UUID orgId, UUID connectionId, Authentication authentication) { diff --git a/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/request/JiraOAuthCallbackRequest.java b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/request/JiraOAuthCallbackRequest.java new file mode 100644 index 00000000..971f2f08 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/request/JiraOAuthCallbackRequest.java @@ -0,0 +1,22 @@ +package com.kntro.reqsai.gateway.interfaces.rest.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import org.jspecify.annotations.Nullable; + +/** + * Request body for the Jira OAuth 2.0 (3LO) callback. {@code code} + {@code state} come from the + * Atlassian redirect; {@code cloudId} is optional and only supplied on the second POST when the user has + * chosen among multiple accessible sites. + */ +@Schema(description = "Jira OAuth callback: authorization code + signed state, optional chosen site") +public record JiraOAuthCallbackRequest( + @Schema(description = "Authorization code from the Atlassian redirect") + @NotBlank String code, + + @Schema(description = "Signed state token issued by the authorize-url endpoint") + @NotBlank String state, + + @Schema(description = "Chosen Atlassian cloud id (only when selecting among multiple sites)") + @Nullable String cloudId +) {} diff --git a/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/JiraOAuthAuthorizeUrlResponse.java b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/JiraOAuthAuthorizeUrlResponse.java new file mode 100644 index 00000000..4b51debe --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/JiraOAuthAuthorizeUrlResponse.java @@ -0,0 +1,10 @@ +package com.kntro.reqsai.gateway.interfaces.rest.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** The Atlassian authorize URL to redirect the user to, plus the signed state embedded in it. */ +@Schema(description = "Jira OAuth authorize URL + signed state") +public record JiraOAuthAuthorizeUrlResponse( + @Schema(description = "Full Atlassian authorize URL") String url, + @Schema(description = "Signed, stateless CSRF state token embedded in the URL") String state +) {} diff --git a/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/JiraOAuthSiteResponse.java b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/JiraOAuthSiteResponse.java new file mode 100644 index 00000000..29e242c9 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/JiraOAuthSiteResponse.java @@ -0,0 +1,11 @@ +package com.kntro.reqsai.gateway.interfaces.rest.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** One accessible Atlassian site offered for selection during the OAuth callback. */ +@Schema(description = "An accessible Atlassian Jira site") +public record JiraOAuthSiteResponse( + @Schema(description = "Atlassian cloud id") String cloudId, + @Schema(description = "Site base URL", example = "https://acme.atlassian.net") String url, + @Schema(description = "Site display name") String name +) {} diff --git a/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/JiraOAuthSitesResponse.java b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/JiraOAuthSitesResponse.java new file mode 100644 index 00000000..7f8ad9af --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/JiraOAuthSitesResponse.java @@ -0,0 +1,15 @@ +package com.kntro.reqsai.gateway.interfaces.rest.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +/** + * Returned by the OAuth callback (HTTP 200) when the user has access to multiple Atlassian sites and has + * not yet chosen one. Nothing was persisted; the frontend re-POSTs the callback with a chosen + * {@code cloudId}. + */ +@Schema(description = "Multiple accessible Jira sites to choose from (no connection saved yet)") +public record JiraOAuthSitesResponse( + List sites +) {} diff --git a/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/mappers/response/IntegrationResponseMapper.java b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/mappers/response/IntegrationResponseMapper.java index b7daddcf..61ddd6bc 100644 --- a/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/mappers/response/IntegrationResponseMapper.java +++ b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/mappers/response/IntegrationResponseMapper.java @@ -2,6 +2,7 @@ import com.kntro.reqsai.gateway.application.port.IntegrationProvider.RemoteIssueType; import com.kntro.reqsai.gateway.application.port.IntegrationProvider.RemoteProject; +import com.kntro.reqsai.gateway.application.port.JiraOAuthPort.Site; import com.kntro.reqsai.gateway.application.result.BatchPushResult; import com.kntro.reqsai.gateway.application.result.ConnectionTestResult; import com.kntro.reqsai.gateway.application.result.StoryPushResult; @@ -11,6 +12,7 @@ import com.kntro.reqsai.gateway.interfaces.rest.dto.response.ConnectionTestResponse; import com.kntro.reqsai.gateway.interfaces.rest.dto.response.IntegrationConnectionResponse; import com.kntro.reqsai.gateway.interfaces.rest.dto.response.JiraIssueTypeResponse; +import com.kntro.reqsai.gateway.interfaces.rest.dto.response.JiraOAuthSiteResponse; import com.kntro.reqsai.gateway.interfaces.rest.dto.response.JiraProjectResponse; import com.kntro.reqsai.gateway.interfaces.rest.dto.response.JiraPushResultResponse; import com.kntro.reqsai.gateway.interfaces.rest.dto.response.ProjectJiraTargetResponse; @@ -48,6 +50,10 @@ public static JiraIssueTypeResponse toResponse(RemoteIssueType t) { return new JiraIssueTypeResponse(t.id(), t.name()); } + public static JiraOAuthSiteResponse toResponse(Site s) { + return new JiraOAuthSiteResponse(s.cloudId(), s.url(), s.name()); + } + public static ProjectJiraTargetResponse toResponse(ProjectIntegrationTarget t) { return new ProjectJiraTargetResponse( t.getId(), diff --git a/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/swagger/OrganizationIntegrationController.java b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/swagger/OrganizationIntegrationController.java index e266120a..0b027970 100644 --- a/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/swagger/OrganizationIntegrationController.java +++ b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/swagger/OrganizationIntegrationController.java @@ -1,9 +1,11 @@ package com.kntro.reqsai.gateway.interfaces.rest.swagger; import com.kntro.reqsai.gateway.interfaces.rest.dto.request.ConnectJiraRequest; +import com.kntro.reqsai.gateway.interfaces.rest.dto.request.JiraOAuthCallbackRequest; import com.kntro.reqsai.gateway.interfaces.rest.dto.response.ConnectionTestResponse; import com.kntro.reqsai.gateway.interfaces.rest.dto.response.IntegrationConnectionResponse; import com.kntro.reqsai.gateway.interfaces.rest.dto.response.JiraIssueTypeResponse; +import com.kntro.reqsai.gateway.interfaces.rest.dto.response.JiraOAuthAuthorizeUrlResponse; import com.kntro.reqsai.gateway.interfaces.rest.dto.response.JiraProjectResponse; import com.kntro.reqsai.shared.infrastructure.configuration.ApiVersioning; import com.kntro.reqsai.shared.infrastructure.documentation.openapi.OpenApiConfiguration; @@ -68,6 +70,43 @@ ResponseEntity connectJira( @Valid @RequestBody ConnectJiraRequest request, Authentication authentication); + @Operation(summary = "Get the Jira OAuth authorize URL", + description = """ + Returns the Atlassian authorize URL (with a stateless signed `state`) to redirect the + user into the OAuth 2.0 (3LO) consent flow. Responds `501 JIRA_OAUTH_NOT_CONFIGURED` + when the deployment has no Jira OAuth app configured (the UI disables the button).""") + @ApiResponse(responseCode = "200", description = "Authorize URL + state", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = JiraOAuthAuthorizeUrlResponse.class))) + @ApiStandardErrorResponses + @SecurityRequirement(name = OpenApiConfiguration.BEARER_SCHEME) + @GetMapping(value = "/jira/oauth/authorize-url", version = ApiVersioning.V1) + ResponseEntity jiraOAuthAuthorizeUrl( + @Parameter(description = "Organization UUID") @PathVariable UUID orgId, + Authentication authentication); + + @Operation(summary = "Complete the Jira OAuth callback", + description = """ + Validates the signed `state`, exchanges the authorization `code`, and discovers the + accessible Atlassian sites. If a `cloudId` is supplied (or exactly one site exists) an + encrypted OAUTH2 connection is saved and returned (`IntegrationConnectionResponse`). + If multiple sites exist and no `cloudId` is given, returns `200 {sites:[...]}` WITHOUT + saving, for the frontend to re-POST with a chosen `cloudId`. `409` when a connection + already exists; `400 JIRA_OAUTH_STATE_INVALID` on a bad state; + `501 JIRA_OAUTH_NOT_CONFIGURED` when OAuth is unconfigured.""") + @ApiResponse(responseCode = "200", + description = "OAUTH2 connection saved, or the list of sites to choose from", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)) + @ApiResponseBadRequest + @ApiResponseConflict + @ApiStandardErrorResponses + @SecurityRequirement(name = OpenApiConfiguration.BEARER_SCHEME) + @PostMapping(value = "/jira/oauth/callback", version = ApiVersioning.V1) + ResponseEntity jiraOAuthCallback( + @Parameter(description = "Organization UUID") @PathVariable UUID orgId, + @Valid @RequestBody JiraOAuthCallbackRequest request, + Authentication authentication); + @Operation(summary = "Test an integration connection", description = "Re-verifies the stored credential against the provider. Never fails the request.") @ApiResponse(responseCode = "200", description = "Test result", From 25ec4fd2efa7553de6c239d00d6f626414b24f21 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Mon, 6 Jul 2026 20:40:21 -0500 Subject: [PATCH 32/72] chore(scripts): add oauth state secret generator --- scripts/generate-oauth-state-secret.sh | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100755 scripts/generate-oauth-state-secret.sh diff --git a/scripts/generate-oauth-state-secret.sh b/scripts/generate-oauth-state-secret.sh new file mode 100755 index 00000000..a5f89164 --- /dev/null +++ b/scripts/generate-oauth-state-secret.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Generates a random secret for signing the Jira OAuth 2.0 (3LO) `state` token (ADR-0022). +# - 32 random bytes, hex-encoded (64 hex chars), used as the raw HMAC-SHA256 key. +# +# The secret is NOT stored in the repo. Paste the printed value into your .env as +# JIRA_OAUTH_STATE_SECRET; in production mount it as a secret (see the deploy workflow). +set -euo pipefail + +SECRET="$(openssl rand -hex 32)" + +echo "Generated Jira OAuth state secret:" +echo +echo "JIRA_OAUTH_STATE_SECRET=$SECRET" +echo +echo "Paste the line above into your .env (do not commit it)." From 4cc5ca95f3d3d0250a6b4a6f8c3aed9703c48b36 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Mon, 6 Jul 2026 20:40:33 -0500 Subject: [PATCH 33/72] fix(gateway): cache oauth tokens across the two-step multi-site callback authorization codes are single-use, so the multi-site callback must not re-exchange the code on the second POST. cache the exchanged tokens + discovered sites under the signed state (5-minute in-memory TTL) and complete the save from the cache when the chosen cloudId arrives, exchanging the code exactly once. also switch the state HMAC to a dedicated JIRA_OAUTH_STATE_SECRET (no reuse of the encryption key) and drop the comments from the oauth yaml block. --- .../config/JiraOAuthProperties.java | 8 +-- .../JiraOAuthCallbackCommandHandler.java | 40 ++++++++--- .../service/JiraOAuthPendingTokenCache.java | 66 +++++++++++++++++++ .../service/JiraOAuthStateService.java | 2 +- src/main/resources/application-dev.yml | 4 +- src/main/resources/application.yml | 7 +- src/test/resources/application-test.yml | 4 +- 7 files changed, 104 insertions(+), 27 deletions(-) create mode 100644 src/main/java/com/kntro/reqsai/gateway/application/service/JiraOAuthPendingTokenCache.java diff --git a/src/main/java/com/kntro/reqsai/gateway/application/config/JiraOAuthProperties.java b/src/main/java/com/kntro/reqsai/gateway/application/config/JiraOAuthProperties.java index 99b0c947..cd0f9c63 100644 --- a/src/main/java/com/kntro/reqsai/gateway/application/config/JiraOAuthProperties.java +++ b/src/main/java/com/kntro/reqsai/gateway/application/config/JiraOAuthProperties.java @@ -14,8 +14,8 @@ * @param clientId the OAuth app client id (blank ⇒ not configured) * @param clientSecret the OAuth app client secret (blank ⇒ not configured) * @param redirectUri the registered callback URL (blank ⇒ not configured) - * @param stateSecret HMAC secret for signing the stateless {@code state} token; defaults to the - * encryption key material when unset + * @param stateSecret dedicated HMAC secret ({@code JIRA_OAUTH_STATE_SECRET}, a random hex string) for + * signing the stateless {@code state} token */ @ConfigurationProperties(prefix = "reqsai.integrations.jira.oauth") public record JiraOAuthProperties( @@ -30,9 +30,9 @@ public boolean configured() { return notBlank(clientId) && notBlank(clientSecret) && notBlank(redirectUri); } - /** The HMAC signing secret, falling back to {@code client-secret} if no dedicated secret is set. */ + /** The dedicated HMAC signing secret (raw UTF-8 key material); empty when unset. */ public String effectiveStateSecret() { - return notBlank(stateSecret) ? stateSecret : (clientSecret == null ? "" : clientSecret); + return notBlank(stateSecret) ? stateSecret : ""; } private static boolean notBlank(@Nullable String value) { diff --git a/src/main/java/com/kntro/reqsai/gateway/application/handler/JiraOAuthCallbackCommandHandler.java b/src/main/java/com/kntro/reqsai/gateway/application/handler/JiraOAuthCallbackCommandHandler.java index 4b9c9fec..a176c260 100644 --- a/src/main/java/com/kntro/reqsai/gateway/application/handler/JiraOAuthCallbackCommandHandler.java +++ b/src/main/java/com/kntro/reqsai/gateway/application/handler/JiraOAuthCallbackCommandHandler.java @@ -6,6 +6,8 @@ import com.kntro.reqsai.gateway.application.port.JiraOAuthPort.OAuthTokens; import com.kntro.reqsai.gateway.application.port.JiraOAuthPort.Site; import com.kntro.reqsai.gateway.application.result.JiraOAuthCallbackResult; +import com.kntro.reqsai.gateway.application.service.JiraOAuthPendingTokenCache; +import com.kntro.reqsai.gateway.application.service.JiraOAuthPendingTokenCache.Pending; import com.kntro.reqsai.gateway.application.service.JiraOAuthStateService; import com.kntro.reqsai.gateway.domain.exception.IntegrationsExceptions; import com.kntro.reqsai.gateway.domain.model.ConnectionStatus; @@ -24,11 +26,14 @@ *
      *
    1. reject if OAuth is not configured ({@code JIRA_OAUTH_NOT_CONFIGURED});
    2. *
    3. validate the signed {@code state} against this org+user ({@code JIRA_OAUTH_STATE_INVALID});
    4. - *
    5. exchange the authorization {@code code} for tokens and discover accessible sites;
    6. + *
    7. exchange the authorization {@code code} for tokens and discover accessible sites — but only ONCE: + * authorization codes are single-use, so the exchanged tokens + sites are cached under the signed + * {@code state}. The second (site-selection) callback reuses the cache and never re-exchanges the + * already-consumed code;
    8. *
    9. if a {@code cloudId} is given use it, else if exactly one site auto-select it, else return the * site list WITHOUT saving (the frontend re-POSTs with a chosen {@code cloudId});
    10. - *
    11. on selection, enforce one active connection per org ({@code INTEGRATION_ALREADY_CONNECTED}) and - * persist an encrypted OAUTH2 connection.
    12. + *
    13. on selection, enforce one active connection per org ({@code INTEGRATION_ALREADY_CONNECTED}), + * persist an encrypted OAUTH2 connection, and evict the cached tokens.
    14. *
    */ @Component @@ -39,6 +44,7 @@ public class JiraOAuthCallbackCommandHandler { private final JiraOAuthStateService stateService; private final JiraOAuthPort oauth; private final IntegrationConnectionRepository connections; + private final JiraOAuthPendingTokenCache pendingTokens; @Transactional public JiraOAuthCallbackResult handle(JiraOAuthCallbackCommand command) { @@ -47,16 +53,28 @@ public JiraOAuthCallbackResult handle(JiraOAuthCallbackCommand command) { } stateService.verify(command.state(), command.organizationId(), command.requestedBy()); - OAuthTokens tokens = oauth.exchangeCode(command.code()); - List sites = oauth.accessibleResources(tokens.accessToken()); - if (sites.isEmpty()) { - // No Jira site is reachable with the granted consent — treat as an auth failure. - throw IntegrationsExceptions.oauthStateInvalid("no accessible Jira sites for the granted consent"); + // Exchange the single-use code at most once per state: on the second (site-selection) callback the + // code is already consumed, so reuse the cached tokens + sites instead of re-exchanging. + Pending pending = pendingTokens.get(command.state()); + OAuthTokens tokens; + List sites; + if (pending != null) { + tokens = pending.tokens(); + sites = pending.sites(); + } else { + tokens = oauth.exchangeCode(command.code()); + sites = oauth.accessibleResources(tokens.accessToken()); + if (sites.isEmpty()) { + // No Jira site is reachable with the granted consent — treat as an auth failure. + throw IntegrationsExceptions.oauthStateInvalid("no accessible Jira sites for the granted consent"); + } } Site chosen = selectSite(sites, command.cloudId()); if (chosen == null) { - // Multiple sites and no cloudId yet: let the frontend choose. Nothing is persisted. + // Multiple sites and no cloudId yet: cache the exchanged tokens + sites under the state so the + // follow-up callback (with a chosen cloudId) completes WITHOUT re-exchanging the used code. + pendingTokens.put(command.state(), tokens, sites); return JiraOAuthCallbackResult.needsSiteSelection(sites); } @@ -72,7 +90,9 @@ public JiraOAuthCallbackResult handle(JiraOAuthCallbackCommand command) { command.organizationId(), IntegrationProviderType.JIRA, chosen.url(), chosen.cloudId(), tokens.refreshToken(), tokens.accessToken(), accessExpiresAt, now); - return JiraOAuthCallbackResult.saved(connections.save(connection)); + IntegrationConnection saved = connections.save(connection); + pendingTokens.evict(command.state()); + return JiraOAuthCallbackResult.saved(saved); } /** diff --git a/src/main/java/com/kntro/reqsai/gateway/application/service/JiraOAuthPendingTokenCache.java b/src/main/java/com/kntro/reqsai/gateway/application/service/JiraOAuthPendingTokenCache.java new file mode 100644 index 00000000..fdd1a994 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/service/JiraOAuthPendingTokenCache.java @@ -0,0 +1,66 @@ +package com.kntro.reqsai.gateway.application.service; + +import com.kntro.reqsai.gateway.application.port.JiraOAuthPort.OAuthTokens; +import com.kntro.reqsai.gateway.application.port.JiraOAuthPort.Site; +import org.jspecify.annotations.Nullable; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Short-lived in-memory cache of a completed OAuth code exchange, keyed by the signed {@code state} + * (ADR-0022). + *

    + * Atlassian authorization codes are SINGLE-USE: the multi-site callback exchanges the code once (to call + * accessible-resources) and, when the user must still pick a site, cannot exchange it again on the second + * callback. The already-exchanged tokens + discovered sites are cached here so the second callback (with + * the chosen {@code cloudId}) completes from the cache without re-consuming the code. Entries expire after + * {@link #TTL} (a few minutes — long enough to pick a site, short enough to bound retention) and are + * removed on use. Tokens live only in memory and are never logged. + */ +@Component +public class JiraOAuthPendingTokenCache { + + /** How long an unfinished multi-site selection is retained before the user must restart the flow. */ + static final Duration TTL = Duration.ofMinutes(5); + + private final Map byState = new ConcurrentHashMap<>(); + + /** Caches the exchanged tokens + discovered sites under {@code state}. */ + public void put(String state, OAuthTokens tokens, List sites) { + byState.put(state, new Entry(tokens, sites, Instant.now().plus(TTL))); + } + + /** Returns the cached exchange for {@code state}, or null if absent/expired (expired entries are purged). */ + public @Nullable Pending get(String state) { + purgeExpired(); + Entry entry = byState.get(state); + if (entry == null) { + return null; + } + if (entry.expiresAt.isBefore(Instant.now())) { + byState.remove(state); + return null; + } + return new Pending(entry.tokens, entry.sites); + } + + /** Removes the cached exchange for {@code state} (called once the connection is saved). */ + public void evict(String state) { + byState.remove(state); + } + + private void purgeExpired() { + Instant now = Instant.now(); + byState.entrySet().removeIf(e -> e.getValue().expiresAt.isBefore(now)); + } + + /** A cached, already-exchanged token set + the sites it can reach. */ + public record Pending(OAuthTokens tokens, List sites) {} + + private record Entry(OAuthTokens tokens, List sites, Instant expiresAt) {} +} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/service/JiraOAuthStateService.java b/src/main/java/com/kntro/reqsai/gateway/application/service/JiraOAuthStateService.java index 37798292..59e2819c 100644 --- a/src/main/java/com/kntro/reqsai/gateway/application/service/JiraOAuthStateService.java +++ b/src/main/java/com/kntro/reqsai/gateway/application/service/JiraOAuthStateService.java @@ -19,7 +19,7 @@ * to the initiating {@code orgId} + {@code userId} with a short expiry and a random nonce, so nothing has * to be stored server-side and it survives the browser redirect. Format: *

    {@code base64url(orgId|userId|expiryEpochSeconds|nonce) + "." + base64url(HMAC-SHA256(payload))}
    - * The HMAC key is {@code reqsai.integrations.jira.oauth.state-secret} (falling back to the client secret). + * The HMAC key is the dedicated {@code reqsai.integrations.jira.oauth.state-secret} ({@code JIRA_OAUTH_STATE_SECRET}). * Verification checks the signature (constant-time), the expiry, and that the org/user match the caller; * any failure raises {@code JIRA_OAUTH_STATE_INVALID}. */ diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index edb4ed26..55968a31 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -35,12 +35,10 @@ reqsai: encryption-key: ${INTEGRATIONS_ENCRYPTION_KEY:AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=} jira: oauth: - # Dummy dev defaults so the OAuth beans wire and the authorize-url endpoint is exercisable - # locally. These are NOT real Atlassian credentials; register a real app and override via .env. client-id: ${JIRA_OAUTH_CLIENT_ID:dev-client-id} client-secret: ${JIRA_OAUTH_CLIENT_SECRET:dev-client-secret} redirect-uri: ${JIRA_OAUTH_CALLBACK_URL:http://localhost:4200/integrations/jira/callback} - state-secret: ${JIRA_OAUTH_STATE_SECRET:AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=} + state-secret: ${JIRA_OAUTH_STATE_SECRET:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef} logging: level: diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 0195f790..019fd222 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -160,15 +160,10 @@ reqsai: encryption-key: ${INTEGRATIONS_ENCRYPTION_KEY:} jira: oauth: - # Jira OAuth 2.0 (3LO) app credentials (ADR-0022). OPTIONAL: when any of client-id/client-secret/ - # redirect-uri is blank the OAuth endpoints return JIRA_OAUTH_NOT_CONFIGURED and the app still - # boots (unlike the encryption key, which is required). Keep secrets out of source control (.env). client-id: ${JIRA_OAUTH_CLIENT_ID:} client-secret: ${JIRA_OAUTH_CLIENT_SECRET:} redirect-uri: ${JIRA_OAUTH_CALLBACK_URL:} - # HMAC secret for signing the stateless OAuth `state` token. Defaults to the encryption key when - # unset so a single configured secret suffices; override with a dedicated value if desired. - state-secret: ${JIRA_OAUTH_STATE_SECRET:${INTEGRATIONS_ENCRYPTION_KEY:}} + state-secret: ${JIRA_OAUTH_STATE_SECRET:} jwt: private-key-path: ${JWT_PRIVATE_KEY_PATH:classpath:certs/private_key.pem} private-key-pem: ${JWT_PRIVATE_KEY_PEM:} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index e11c8fc0..b071b718 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -27,12 +27,10 @@ reqsai: encryption-key: AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8= jira: oauth: - # Deterministic dummy OAuth config so the OAuth endpoints are configured (not disabled) in tests. - # No test hits real Atlassian — the JiraOAuthClient boundary is stubbed. client-id: test-client-id client-secret: test-client-secret redirect-uri: http://localhost/integrations/jira/callback - state-secret: AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8= + state-secret: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef cors: allowed-origins: http://localhost:4200 allow-credentials: true From afd10d83c45366281242c36ce50959dba46c2f09 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Mon, 6 Jul 2026 20:40:43 -0500 Subject: [PATCH 34/72] test(gateway): cover jira oauth state, callback, refresh and dual-mode routing unit: state sign/verify (valid, tampered, expired, wrong org/user), callback handler (single-site auto-select, multi-site returns sites, two-step exchanges the code once, already-connected 409, invalid state, unconfigured), token refresh-before-call (cached when valid, refresh + persist rotated, keep refresh when not rotated, failure -> auth failed), credentials factory routing, and JiraApiContext base-url/auth selection. integration: OAuth callback persists an encrypted OAUTH2 connection (tokens never echoed) and a push routes to the api.atlassian.com/ex/jira/{cloudId} base. update the existing api-token tests to the new ProviderCredentials factory (regression kept green). --- .../reqsai/gateway/StubJiraOAuthConfig.java | 85 ++++++++ .../JiraOAuthCallbackCommandHandlerTest.java | 188 ++++++++++++++++++ .../PushAllStoriesCommandHandlerTest.java | 2 +- .../handler/PushStoryCommandHandlerTest.java | 2 +- .../TestConnectionQueryHandlerTest.java | 4 +- .../service/JiraOAuthStateServiceTest.java | 113 +++++++++++ .../service/JiraOAuthTokenServiceTest.java | 103 ++++++++++ .../ProviderCredentialsFactoryTest.java | 58 ++++++ .../jira/JiraApiContextTest.java | 37 ++++ .../rest/JiraOAuthIntegrationTest.java | 169 ++++++++++++++++ 10 files changed, 757 insertions(+), 4 deletions(-) create mode 100644 src/test/java/com/kntro/reqsai/gateway/StubJiraOAuthConfig.java create mode 100644 src/test/java/com/kntro/reqsai/gateway/application/handler/JiraOAuthCallbackCommandHandlerTest.java create mode 100644 src/test/java/com/kntro/reqsai/gateway/application/service/JiraOAuthStateServiceTest.java create mode 100644 src/test/java/com/kntro/reqsai/gateway/application/service/JiraOAuthTokenServiceTest.java create mode 100644 src/test/java/com/kntro/reqsai/gateway/application/service/ProviderCredentialsFactoryTest.java create mode 100644 src/test/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraApiContextTest.java create mode 100644 src/test/java/com/kntro/reqsai/gateway/interfaces/rest/JiraOAuthIntegrationTest.java diff --git a/src/test/java/com/kntro/reqsai/gateway/StubJiraOAuthConfig.java b/src/test/java/com/kntro/reqsai/gateway/StubJiraOAuthConfig.java new file mode 100644 index 00000000..d894a903 --- /dev/null +++ b/src/test/java/com/kntro/reqsai/gateway/StubJiraOAuthConfig.java @@ -0,0 +1,85 @@ +package com.kntro.reqsai.gateway; + +import com.kntro.reqsai.gateway.application.port.JiraOAuthPort; +import com.kntro.reqsai.gateway.infrastructure.jira.JiraClient; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * Stubs the Atlassian/Jira HTTP boundary for the OAuth integration test WITHOUT touching the network: + *
      + *
    • {@link JiraOAuthPort} — canned code exchange (fixed access/refresh + short expiry) and a single + * accessible site {@code cloud-1 / https://acme.atlassian.net}.
    • + *
    • {@link JiraClient} — a recording subclass that returns canned verify/project/issue-type/create + * results and CAPTURES the {@code apiBase} of every call, so the test can assert an OAUTH2 push + * routes to the {@code https://api.atlassian.com/ex/jira/{cloudId}/rest/api/3} base.
    • + *
    + * The real {@code JiraProvider}, {@code ProviderCredentialsFactory}, {@code JiraOAuthTokenService} and + * the callback handler run end-to-end so encryption + persistence + dual-mode routing are exercised. + */ +@TestConfiguration +public class StubJiraOAuthConfig { + + /** Records every API base URL the client was called with (for routing assertions). */ + public static final class RecordingJiraClient extends JiraClient { + public final List apiBases = new CopyOnWriteArrayList<>(); + + @Override + public String verify(JiraApiContext ctx) { + apiBases.add(ctx.apiBase()); + return "Stub OAuth Admin"; + } + + @Override + public List listProjects(JiraApiContext ctx) { + apiBases.add(ctx.apiBase()); + return List.of(new JiraProject("PAY", "Payments")); + } + + @Override + public List listIssueTypes(JiraApiContext ctx, String projectKey) { + apiBases.add(ctx.apiBase()); + return List.of(new JiraIssueType("10001", "Story")); + } + + @Override + public CreatedIssue createIssue(JiraApiContext ctx, String projectKey, String issueTypeName, + String summary, Map descriptionAdf) { + apiBases.add(ctx.apiBase()); + return new CreatedIssue(projectKey + "-42", ctx.apiBase() + "/issue/" + projectKey + "-42"); + } + } + + @Bean + @Primary + public JiraClient recordingJiraClient() { + return new RecordingJiraClient(); + } + + @Bean + @Primary + public JiraOAuthPort stubJiraOAuthPort() { + return new JiraOAuthPort() { + @Override + public OAuthTokens exchangeCode(String code) { + // Short-lived access token so a subsequent push exercises the refresh-before-call path too. + return new OAuthTokens("access-token-1", "refresh-token-1", 3600, "read:jira-work offline_access"); + } + + @Override + public OAuthTokens refresh(String refreshToken) { + return new OAuthTokens("access-token-refreshed", "refresh-token-rotated", 3600, "read:jira-work"); + } + + @Override + public List accessibleResources(String accessToken) { + return List.of(new Site("cloud-1", "https://acme.atlassian.net", "Acme")); + } + }; + } +} diff --git a/src/test/java/com/kntro/reqsai/gateway/application/handler/JiraOAuthCallbackCommandHandlerTest.java b/src/test/java/com/kntro/reqsai/gateway/application/handler/JiraOAuthCallbackCommandHandlerTest.java new file mode 100644 index 00000000..362bc5d7 --- /dev/null +++ b/src/test/java/com/kntro/reqsai/gateway/application/handler/JiraOAuthCallbackCommandHandlerTest.java @@ -0,0 +1,188 @@ +package com.kntro.reqsai.gateway.application.handler; + +import com.kntro.reqsai.gateway.application.command.JiraOAuthCallbackCommand; +import com.kntro.reqsai.gateway.application.config.JiraOAuthProperties; +import com.kntro.reqsai.gateway.application.port.IntegrationConnectionRepository; +import com.kntro.reqsai.gateway.application.port.JiraOAuthPort; +import com.kntro.reqsai.gateway.application.port.JiraOAuthPort.OAuthTokens; +import com.kntro.reqsai.gateway.application.port.JiraOAuthPort.Site; +import com.kntro.reqsai.gateway.application.result.JiraOAuthCallbackResult; +import com.kntro.reqsai.gateway.application.service.JiraOAuthPendingTokenCache; +import com.kntro.reqsai.gateway.application.service.JiraOAuthStateService; +import com.kntro.reqsai.gateway.domain.model.ConnectionStatus; +import com.kntro.reqsai.gateway.domain.model.CredentialType; +import com.kntro.reqsai.gateway.domain.model.IntegrationConnection; +import com.kntro.reqsai.gateway.domain.model.IntegrationProviderType; +import com.kntro.reqsai.shared.domain.exception.DomainException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@DisplayName("Application: Jira OAuth callback") +@ExtendWith(MockitoExtension.class) +class JiraOAuthCallbackCommandHandlerTest { + + private static final JiraOAuthProperties PROPS = new JiraOAuthProperties( + "client-id", "client-secret", "https://cb", "state-secret-material-0123456789"); + + @Mock + private JiraOAuthPort oauth; + @Mock + private IntegrationConnectionRepository connections; + + private JiraOAuthStateService stateService; + private JiraOAuthPendingTokenCache pendingTokens; + private JiraOAuthCallbackCommandHandler handler; + + private final UUID orgId = UUID.randomUUID(); + private final UUID userId = UUID.randomUUID(); + + @BeforeEach + void setUp() { + stateService = new JiraOAuthStateService(PROPS); + pendingTokens = new JiraOAuthPendingTokenCache(); // real cache to exercise the two-step flow + handler = new JiraOAuthCallbackCommandHandler(PROPS, stateService, oauth, connections, pendingTokens); + } + + private JiraOAuthCallbackCommand command(String cloudId) { + return new JiraOAuthCallbackCommand(orgId, "auth-code", stateService.issue(orgId, userId), cloudId, userId); + } + + private JiraOAuthCallbackCommand command(String state, String cloudId) { + return new JiraOAuthCallbackCommand(orgId, "auth-code", state, cloudId, userId); + } + + private OAuthTokens tokens() { + return new OAuthTokens("access-abc", "refresh-xyz", 3600, "read:jira-work offline_access"); + } + + @Test + @DisplayName("single accessible site auto-selects and persists an encrypted OAUTH2 connection") + void single_site_auto_selects() { + when(oauth.exchangeCode("auth-code")).thenReturn(tokens()); + when(oauth.accessibleResources("access-abc")) + .thenReturn(List.of(new Site("cloud-1", "https://acme.atlassian.net", "Acme"))); + when(connections.existsByOrganizationIdAndProviderAndStatusNot( + any(), any(), any())).thenReturn(false); + when(connections.save(any(IntegrationConnection.class))).thenAnswer(i -> i.getArgument(0)); + + JiraOAuthCallbackResult result = handler.handle(command(null)); + + assertThat(result.isSaved()).isTrue(); + IntegrationConnection saved = result.connection(); + assertThat(saved.getCredentialType()).isEqualTo(CredentialType.OAUTH2); + assertThat(saved.getCloudId()).isEqualTo("cloud-1"); + assertThat(saved.getSiteUrl()).isEqualTo("https://acme.atlassian.net"); + assertThat(saved.getEmail()).isNull(); + assertThat(saved.getOauthRefreshToken()).isEqualTo("refresh-xyz"); + assertThat(saved.getOauthAccessToken()).isEqualTo("access-abc"); + assertThat(saved.getStatus()).isEqualTo(ConnectionStatus.CONNECTED); + } + + @Test + @DisplayName("multiple sites without a cloudId returns the site list and saves nothing") + void multi_site_returns_sites() { + when(oauth.exchangeCode("auth-code")).thenReturn(tokens()); + when(oauth.accessibleResources("access-abc")).thenReturn(List.of( + new Site("cloud-1", "https://acme.atlassian.net", "Acme"), + new Site("cloud-2", "https://beta.atlassian.net", "Beta"))); + + JiraOAuthCallbackResult result = handler.handle(command(null)); + + assertThat(result.isSaved()).isFalse(); + assertThat(result.sites()).extracting(Site::cloudId).containsExactly("cloud-1", "cloud-2"); + verify(connections, never()).save(any()); + } + + @Test + @DisplayName("two-step multi-site flow exchanges the single-use code exactly once and reuses the cache") + void two_step_multi_site_exchanges_code_once() { + when(oauth.exchangeCode("auth-code")).thenReturn(tokens()); + when(oauth.accessibleResources("access-abc")).thenReturn(List.of( + new Site("cloud-1", "https://acme.atlassian.net", "Acme"), + new Site("cloud-2", "https://beta.atlassian.net", "Beta"))); + when(connections.existsByOrganizationIdAndProviderAndStatusNot(any(), any(), any())).thenReturn(false); + when(connections.save(any(IntegrationConnection.class))).thenAnswer(i -> i.getArgument(0)); + + // Step 1: no cloudId -> returns sites, caches tokens under the state (nothing saved yet). + String state = stateService.issue(orgId, userId); + JiraOAuthCallbackResult first = handler.handle(command(state, null)); + assertThat(first.isSaved()).isFalse(); + verify(connections, never()).save(any()); + + // Step 2: same state + chosen cloudId -> completes from the cache, saves, does NOT re-exchange. + JiraOAuthCallbackResult second = handler.handle(command(state, "cloud-2")); + assertThat(second.isSaved()).isTrue(); + assertThat(second.connection().getCloudId()).isEqualTo("cloud-2"); + + // The single-use code was exchanged exactly once and accessible-resources called exactly once. + verify(oauth, times(1)).exchangeCode("auth-code"); + verify(oauth, times(1)).accessibleResources("access-abc"); + } + + @Test + @DisplayName("a chosen cloudId among multiple sites persists that site") + void chosen_cloud_id_persists() { + when(oauth.exchangeCode("auth-code")).thenReturn(tokens()); + when(oauth.accessibleResources("access-abc")).thenReturn(List.of( + new Site("cloud-1", "https://acme.atlassian.net", "Acme"), + new Site("cloud-2", "https://beta.atlassian.net", "Beta"))); + when(connections.existsByOrganizationIdAndProviderAndStatusNot(any(), any(), any())).thenReturn(false); + when(connections.save(any(IntegrationConnection.class))).thenAnswer(i -> i.getArgument(0)); + + JiraOAuthCallbackResult result = handler.handle(command("cloud-2")); + + assertThat(result.isSaved()).isTrue(); + assertThat(result.connection().getCloudId()).isEqualTo("cloud-2"); + assertThat(result.connection().getSiteUrl()).isEqualTo("https://beta.atlassian.net"); + } + + @Test + @DisplayName("rejects a second active connection with a 409 domain error") + void rejects_already_connected() { + when(oauth.exchangeCode("auth-code")).thenReturn(tokens()); + when(oauth.accessibleResources("access-abc")) + .thenReturn(List.of(new Site("cloud-1", "https://acme.atlassian.net", "Acme"))); + when(connections.existsByOrganizationIdAndProviderAndStatusNot( + any(), any(), any())).thenReturn(true); + + assertThatThrownBy(() -> handler.handle(command(null))) + .isInstanceOf(DomainException.class); + verify(connections, never()).save(any()); + } + + @Test + @DisplayName("a tampered state is rejected before any token exchange") + void rejects_invalid_state() { + JiraOAuthCallbackCommand bad = new JiraOAuthCallbackCommand( + orgId, "auth-code", "bogus.state", null, userId); + + assertThatThrownBy(() -> handler.handle(bad)).isInstanceOf(DomainException.class); + verify(oauth, never()).exchangeCode(any()); + } + + @Test + @DisplayName("unconfigured oauth is rejected with JIRA_OAUTH_NOT_CONFIGURED") + void rejects_unconfigured() { + JiraOAuthProperties unconfigured = new JiraOAuthProperties(null, null, null, null); + JiraOAuthCallbackCommandHandler h = new JiraOAuthCallbackCommandHandler( + unconfigured, stateService, oauth, connections, pendingTokens); + + assertThatThrownBy(() -> h.handle(command(null))).isInstanceOf(DomainException.class); + verify(oauth, never()).exchangeCode(any()); + } +} diff --git a/src/test/java/com/kntro/reqsai/gateway/application/handler/PushAllStoriesCommandHandlerTest.java b/src/test/java/com/kntro/reqsai/gateway/application/handler/PushAllStoriesCommandHandlerTest.java index 99bdf674..8be4b288 100644 --- a/src/test/java/com/kntro/reqsai/gateway/application/handler/PushAllStoriesCommandHandlerTest.java +++ b/src/test/java/com/kntro/reqsai/gateway/application/handler/PushAllStoriesCommandHandlerTest.java @@ -66,7 +66,7 @@ void captures_partial_failure() { when(targets.findByProjectId(projectId)).thenReturn(Optional.of(target)); when(connections.findById(connectionId)).thenReturn(Optional.of(connection)); when(credentialsFactory.from(connection)).thenReturn( - new IntegrationProvider.ProviderCredentials("https://acme.atlassian.net", "pm@acme.com", "tok")); + IntegrationProvider.ProviderCredentials.apiToken("https://acme.atlassian.net", "pm@acme.com", "tok")); StoryView ok = story(projectId, "Good story"); StoryView bad = story(projectId, "Bad story"); diff --git a/src/test/java/com/kntro/reqsai/gateway/application/handler/PushStoryCommandHandlerTest.java b/src/test/java/com/kntro/reqsai/gateway/application/handler/PushStoryCommandHandlerTest.java index 524a463b..f177b857 100644 --- a/src/test/java/com/kntro/reqsai/gateway/application/handler/PushStoryCommandHandlerTest.java +++ b/src/test/java/com/kntro/reqsai/gateway/application/handler/PushStoryCommandHandlerTest.java @@ -68,7 +68,7 @@ void pushes_story() { when(stories.findStory(projectId, storyId)).thenReturn(Optional.of(story)); when(connections.findById(connectionId)).thenReturn(Optional.of(connection)); when(credentialsFactory.from(connection)).thenReturn( - new IntegrationProvider.ProviderCredentials("https://acme.atlassian.net", "pm@acme.com", "tok")); + IntegrationProvider.ProviderCredentials.apiToken("https://acme.atlassian.net", "pm@acme.com", "tok")); when(jiraProvider.pushStory(any(), any(), any(), any())) .thenReturn(new PushedIssue("PAY-7", "https://acme.atlassian.net/browse/PAY-7")); diff --git a/src/test/java/com/kntro/reqsai/gateway/application/handler/TestConnectionQueryHandlerTest.java b/src/test/java/com/kntro/reqsai/gateway/application/handler/TestConnectionQueryHandlerTest.java index 4d3ceb1f..06e52b29 100644 --- a/src/test/java/com/kntro/reqsai/gateway/application/handler/TestConnectionQueryHandlerTest.java +++ b/src/test/java/com/kntro/reqsai/gateway/application/handler/TestConnectionQueryHandlerTest.java @@ -51,7 +51,7 @@ void ok_on_success() { IntegrationConnection connection = connection(orgId); when(connections.findByIdAndOrganizationId(connectionId, orgId)).thenReturn(Optional.of(connection)); when(credentialsFactory.from(connection)).thenReturn( - new IntegrationProvider.ProviderCredentials("https://acme.atlassian.net", "pm@acme.com", "tok")); + IntegrationProvider.ProviderCredentials.apiToken("https://acme.atlassian.net", "pm@acme.com", "tok")); when(jiraProvider.verify(any())).thenReturn("Jane Admin"); ConnectionTestResult result = handler.handle(new TestConnectionQuery(orgId, connectionId, UUID.randomUUID())); @@ -69,7 +69,7 @@ void degraded_on_failure() { IntegrationConnection connection = connection(orgId); when(connections.findByIdAndOrganizationId(connectionId, orgId)).thenReturn(Optional.of(connection)); when(credentialsFactory.from(connection)).thenReturn( - new IntegrationProvider.ProviderCredentials("https://acme.atlassian.net", "pm@acme.com", "tok")); + IntegrationProvider.ProviderCredentials.apiToken("https://acme.atlassian.net", "pm@acme.com", "tok")); when(jiraProvider.verify(any())).thenThrow(IntegrationsInfrastructureExceptions.jiraAuthFailed()); ConnectionTestResult result = handler.handle(new TestConnectionQuery(orgId, connectionId, UUID.randomUUID())); diff --git a/src/test/java/com/kntro/reqsai/gateway/application/service/JiraOAuthStateServiceTest.java b/src/test/java/com/kntro/reqsai/gateway/application/service/JiraOAuthStateServiceTest.java new file mode 100644 index 00000000..2573935b --- /dev/null +++ b/src/test/java/com/kntro/reqsai/gateway/application/service/JiraOAuthStateServiceTest.java @@ -0,0 +1,113 @@ +package com.kntro.reqsai.gateway.application.service; + +import com.kntro.reqsai.gateway.application.config.JiraOAuthProperties; +import com.kntro.reqsai.gateway.domain.exception.IntegrationsError; +import com.kntro.reqsai.shared.domain.exception.DomainException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DisplayName("Application: Jira OAuth state sign/verify") +class JiraOAuthStateServiceTest { + + private static final JiraOAuthProperties PROPS = new JiraOAuthProperties( + "client-id", "client-secret", "https://cb", "state-secret-material-0123456789"); + + private final JiraOAuthStateService service = new JiraOAuthStateService(PROPS); + + @Test + @DisplayName("a freshly issued state verifies for its org+user") + void round_trips() { + UUID org = UUID.randomUUID(); + UUID user = UUID.randomUUID(); + + String state = service.issue(org, user); + + assertThat(state).contains("."); + service.verify(state, org, user); // does not throw + } + + @Test + @DisplayName("a tampered payload fails signature verification") + void rejects_tampered() { + UUID org = UUID.randomUUID(); + UUID user = UUID.randomUUID(); + String state = service.issue(org, user); + // Flip the last character of the payload segment. + int dot = state.indexOf('.'); + char[] chars = state.toCharArray(); + chars[dot - 1] = chars[dot - 1] == 'A' ? 'B' : 'A'; + String tampered = new String(chars); + + assertThatThrownBy(() -> service.verify(tampered, org, user)) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).error()) + .isEqualTo(IntegrationsError.JIRA_OAUTH_STATE_INVALID); + } + + @Test + @DisplayName("a state issued for a different org is rejected") + void rejects_wrong_org() { + UUID user = UUID.randomUUID(); + String state = service.issue(UUID.randomUUID(), user); + + assertThatThrownBy(() -> service.verify(state, UUID.randomUUID(), user)) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).error()) + .isEqualTo(IntegrationsError.JIRA_OAUTH_STATE_INVALID); + } + + @Test + @DisplayName("a state issued for a different user is rejected") + void rejects_wrong_user() { + UUID org = UUID.randomUUID(); + String state = service.issue(org, UUID.randomUUID()); + + assertThatThrownBy(() -> service.verify(state, org, UUID.randomUUID())) + .isInstanceOf(DomainException.class); + } + + @Test + @DisplayName("an expired state is rejected") + void rejects_expired() { + UUID org = UUID.randomUUID(); + UUID user = UUID.randomUUID(); + // Build a state whose payload expiry is in the past but signed with the real secret, so only the + // expiry check fails (not the signature). Reuses the service's own signing via reflection-free + // reconstruction: issue then rewrite the expiry is not possible (would break the signature), so we + // sign a hand-built expired payload the same way the service does. + String expired = signExpired(org, user); + + assertThatThrownBy(() -> service.verify(expired, org, user)) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).error()) + .isEqualTo(IntegrationsError.JIRA_OAUTH_STATE_INVALID); + } + + @Test + @DisplayName("a malformed state (no signature separator) is rejected") + void rejects_malformed() { + assertThatThrownBy(() -> service.verify("not-a-valid-state", UUID.randomUUID(), UUID.randomUUID())) + .isInstanceOf(DomainException.class); + } + + /** Signs an already-expired payload with the same HMAC the service uses, to exercise the expiry branch. */ + private static String signExpired(UUID org, UUID user) { + try { + String payload = "%s|%s|%d|%s".formatted(org, user, 1L, "noncevalue"); + var b64 = java.util.Base64.getUrlEncoder().withoutPadding(); + String encodedPayload = b64.encodeToString(payload.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + javax.crypto.Mac mac = javax.crypto.Mac.getInstance("HmacSHA256"); + mac.init(new javax.crypto.spec.SecretKeySpec( + PROPS.effectiveStateSecret().getBytes(java.nio.charset.StandardCharsets.UTF_8), "HmacSHA256")); + byte[] sig = mac.doFinal(encodedPayload.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + return encodedPayload + "." + b64.encodeToString(sig); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } +} diff --git a/src/test/java/com/kntro/reqsai/gateway/application/service/JiraOAuthTokenServiceTest.java b/src/test/java/com/kntro/reqsai/gateway/application/service/JiraOAuthTokenServiceTest.java new file mode 100644 index 00000000..48ecc23a --- /dev/null +++ b/src/test/java/com/kntro/reqsai/gateway/application/service/JiraOAuthTokenServiceTest.java @@ -0,0 +1,103 @@ +package com.kntro.reqsai.gateway.application.service; + +import com.kntro.reqsai.gateway.application.port.IntegrationConnectionRepository; +import com.kntro.reqsai.gateway.application.port.JiraOAuthPort; +import com.kntro.reqsai.gateway.application.port.JiraOAuthPort.OAuthTokens; +import com.kntro.reqsai.gateway.domain.model.IntegrationConnection; +import com.kntro.reqsai.gateway.domain.model.IntegrationProviderType; +import com.kntro.reqsai.shared.domain.exception.InfrastructureException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@DisplayName("Application: Jira OAuth token refresh-before-call") +@ExtendWith(MockitoExtension.class) +class JiraOAuthTokenServiceTest { + + @Mock + private JiraOAuthPort oauth; + @Mock + private IntegrationConnectionRepository connections; + + private JiraOAuthTokenService service() { + return new JiraOAuthTokenService(oauth, connections); + } + + private IntegrationConnection oauthConnection(Instant accessExpiry) { + return IntegrationConnection.oauth( + UUID.randomUUID(), IntegrationProviderType.JIRA, + "https://acme.atlassian.net", "cloud-1", + "refresh-old", "access-old", accessExpiry, Instant.now()); + } + + @Test + @DisplayName("returns the cached access token when it is still valid") + void uses_cached_when_valid() { + IntegrationConnection connection = oauthConnection(Instant.now().plus(30, ChronoUnit.MINUTES)); + + String token = service().freshAccessToken(connection); + + assertThat(token).isEqualTo("access-old"); + verify(oauth, never()).refresh(any()); + verify(connections, never()).save(any()); + } + + @Test + @DisplayName("refreshes and persists rotated tokens when the access token has expired") + void refreshes_when_expired() { + IntegrationConnection connection = oauthConnection(Instant.now().minus(1, ChronoUnit.MINUTES)); + when(oauth.refresh("refresh-old")) + .thenReturn(new OAuthTokens("access-new", "refresh-rotated", 3600, "scope")); + when(connections.save(any(IntegrationConnection.class))).thenAnswer(i -> i.getArgument(0)); + + String token = service().freshAccessToken(connection); + + assertThat(token).isEqualTo("access-new"); + // Rotated refresh token is persisted; the cached access token + expiry are updated. + assertThat(connection.getOauthRefreshToken()).isEqualTo("refresh-rotated"); + assertThat(connection.getOauthAccessToken()).isEqualTo("access-new"); + assertThat(connection.getOauthAccessExpiresAt()).isAfter(Instant.now()); + verify(connections).save(connection); + } + + @Test + @DisplayName("keeps the existing refresh token when the refresh response omits a new one") + void keeps_refresh_when_not_rotated() { + IntegrationConnection connection = oauthConnection(Instant.now().minus(1, ChronoUnit.MINUTES)); + when(oauth.refresh("refresh-old")) + .thenReturn(new OAuthTokens("access-new", null, 3600, "scope")); + when(connections.save(any(IntegrationConnection.class))).thenAnswer(i -> i.getArgument(0)); + + service().freshAccessToken(connection); + + assertThat(connection.getOauthRefreshToken()).isEqualTo("refresh-old"); + assertThat(connection.getOauthAccessToken()).isEqualTo("access-new"); + } + + @Test + @DisplayName("a refresh failure surfaces as JIRA_AUTH_FAILED") + void refresh_failure_maps_to_auth_failed() { + IntegrationConnection connection = oauthConnection(Instant.now().minus(1, ChronoUnit.MINUTES)); + when(oauth.refresh("refresh-old")) + .thenThrow(com.kntro.reqsai.gateway.infrastructure.exception.IntegrationsInfrastructureExceptions + .jiraOauthExchangeFailed("boom", null)); + + assertThatThrownBy(() -> service().freshAccessToken(connection)) + .isInstanceOf(InfrastructureException.class) + .extracting(e -> ((InfrastructureException) e).error().code()) + .isEqualTo("JIRA_AUTH_FAILED"); + } +} diff --git a/src/test/java/com/kntro/reqsai/gateway/application/service/ProviderCredentialsFactoryTest.java b/src/test/java/com/kntro/reqsai/gateway/application/service/ProviderCredentialsFactoryTest.java new file mode 100644 index 00000000..adfd0062 --- /dev/null +++ b/src/test/java/com/kntro/reqsai/gateway/application/service/ProviderCredentialsFactoryTest.java @@ -0,0 +1,58 @@ +package com.kntro.reqsai.gateway.application.service; + +import com.kntro.reqsai.gateway.application.port.IntegrationProvider.ProviderCredentials; +import com.kntro.reqsai.gateway.domain.model.CredentialType; +import com.kntro.reqsai.gateway.domain.model.IntegrationConnection; +import com.kntro.reqsai.gateway.domain.model.IntegrationProviderType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@DisplayName("Application: ProviderCredentialsFactory dual-mode routing") +@ExtendWith(MockitoExtension.class) +class ProviderCredentialsFactoryTest { + + @Mock + private JiraOAuthTokenService oauthTokens; + + @Test + @DisplayName("API_TOKEN connection yields basic-auth credentials from email + token") + void api_token_routing() { + IntegrationConnection connection = new IntegrationConnection( + UUID.randomUUID(), IntegrationProviderType.JIRA, + "https://acme.atlassian.net", "pm@acme.com", "tok", Instant.now()); + + ProviderCredentials creds = new ProviderCredentialsFactory(oauthTokens).from(connection); + + assertThat(creds.credentialType()).isEqualTo(CredentialType.API_TOKEN); + assertThat(creds.email()).isEqualTo("pm@acme.com"); + assertThat(creds.apiToken()).isEqualTo("tok"); + assertThat(creds.accessToken()).isNull(); + } + + @Test + @DisplayName("OAUTH2 connection yields bearer credentials with a freshly resolved access token") + void oauth_routing_uses_fresh_token() { + IntegrationConnection connection = IntegrationConnection.oauth( + UUID.randomUUID(), IntegrationProviderType.JIRA, + "https://acme.atlassian.net", "cloud-1", "refresh", "access-stale", + Instant.now().minus(1, ChronoUnit.MINUTES), Instant.now()); + when(oauthTokens.freshAccessToken(connection)).thenReturn("access-fresh"); + + ProviderCredentials creds = new ProviderCredentialsFactory(oauthTokens).from(connection); + + assertThat(creds.credentialType()).isEqualTo(CredentialType.OAUTH2); + assertThat(creds.cloudId()).isEqualTo("cloud-1"); + assertThat(creds.accessToken()).isEqualTo("access-fresh"); + assertThat(creds.email()).isNull(); + } +} diff --git a/src/test/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraApiContextTest.java b/src/test/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraApiContextTest.java new file mode 100644 index 00000000..a2951282 --- /dev/null +++ b/src/test/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraApiContextTest.java @@ -0,0 +1,37 @@ +package com.kntro.reqsai.gateway.infrastructure.jira; + +import com.kntro.reqsai.gateway.infrastructure.jira.JiraClient.JiraApiContext; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("Infrastructure: dual-mode Jira API base URL + auth selection") +class JiraApiContextTest { + + @Test + @DisplayName("API_TOKEN mode uses the site base URL and Basic auth") + void api_token_context() { + JiraApiContext ctx = JiraApiContext.apiToken("https://acme.atlassian.net", "pm@acme.com", "tok"); + + assertThat(ctx.apiBase()).isEqualTo("https://acme.atlassian.net/rest/api/3"); + assertThat(ctx.browseBase()).isEqualTo("https://acme.atlassian.net"); + assertThat(ctx.authHeader()).startsWith("Basic "); + String decoded = new String(Base64.getDecoder().decode(ctx.authHeader().substring("Basic ".length())), + StandardCharsets.UTF_8); + assertThat(decoded).isEqualTo("pm@acme.com:tok"); + } + + @Test + @DisplayName("OAUTH2 mode uses the api.atlassian.com/ex/jira/{cloudId} base and Bearer auth") + void oauth_context() { + JiraApiContext ctx = JiraApiContext.oauth("cloud-1", "access-abc", "https://acme.atlassian.net"); + + assertThat(ctx.apiBase()).isEqualTo("https://api.atlassian.com/ex/jira/cloud-1/rest/api/3"); + assertThat(ctx.browseBase()).isEqualTo("https://acme.atlassian.net"); // browse links use the human site + assertThat(ctx.authHeader()).isEqualTo("Bearer access-abc"); + } +} diff --git a/src/test/java/com/kntro/reqsai/gateway/interfaces/rest/JiraOAuthIntegrationTest.java b/src/test/java/com/kntro/reqsai/gateway/interfaces/rest/JiraOAuthIntegrationTest.java new file mode 100644 index 00000000..bd51458e --- /dev/null +++ b/src/test/java/com/kntro/reqsai/gateway/interfaces/rest/JiraOAuthIntegrationTest.java @@ -0,0 +1,169 @@ +package com.kntro.reqsai.gateway.interfaces.rest; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.kntro.reqsai.gateway.StubJiraOAuthConfig; +import com.kntro.reqsai.testsupport.AbstractIntegrationTest; +import com.kntro.reqsai.testsupport.StubEmbeddingConfig; +import com.kntro.reqsai.testsupport.TestJwtFactory; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * End-to-end test of the Jira OAuth 2.0 (3LO) slice: creates an org, fetches the authorize URL (obtaining + * a real signed state), completes the callback (single accessible site auto-selects), then sets a target, + * seeds a story and pushes it. Asserts an OAUTH2 connection persists with ENCRYPTED tokens (never echoed) + * and that the push routes to the {@code api.atlassian.com/ex/jira/{cloudId}} OAuth base. + *

    + * The Atlassian/Jira HTTP boundary is stubbed via {@link StubJiraOAuthConfig} — no real network. + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +@Import({StubJiraOAuthConfig.class, StubEmbeddingConfig.class}) +@Tag("integration") +@DisplayName("Integration: Jira OAuth connect, target and push") +class JiraOAuthIntegrationTest extends AbstractIntegrationTest { + + private static final String USER_ID = "00000000-0000-0000-0000-000000000001"; + private static final ObjectMapper JSON = new ObjectMapper(); + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private StubJiraOAuthConfig.RecordingJiraClient recordingJiraClient; + + @Test + @DisplayName("completes the OAuth callback, persists encrypted tokens and pushes via the OAuth base") + void oauth_connect_target_and_push() throws Exception { + String suffix = UUID.randomUUID().toString().substring(0, 8); + String slug = "oauth-" + suffix; + String schema = "tenant_" + slug; + String orgId = createOrg(suffix, slug); + UUID projectId = createProject(orgId, schema, "OAuth Platform " + suffix); + + // 1. Get the authorize URL -> yields a real signed state bound to this org+user. + ResponseEntity authUrlRes = client().get() + .uri("/api/organizations/{orgId}/integrations/jira/oauth/authorize-url", orgId) + .header("Authorization", TestJwtFactory.bearer(USER_ID, orgId, "ROLE_USER")) + .header("Api-Version", "1") + .exchange((req, res) -> ResponseEntity.status(res.getStatusCode()).body(res.bodyTo(String.class))); + assertThat(authUrlRes.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode authUrl = JSON.readTree(authUrlRes.getBody()); + assertThat(authUrl.get("url").asText()).startsWith("https://auth.atlassian.com/authorize"); + String state = authUrl.get("state").asText(); + + // 2. Complete the callback (single site auto-selects) -> 201 OAUTH2 connection, tokens NOT echoed. + ResponseEntity callbackRes = client().post() + .uri("/api/organizations/{orgId}/integrations/jira/oauth/callback", orgId) + .header("Authorization", TestJwtFactory.bearer(USER_ID, orgId, "ROLE_USER")) + .header("Api-Version", "1") + .contentType(MediaType.APPLICATION_JSON) + .body(Map.of("code", "auth-code", "state", state)) + .exchange((req, res) -> ResponseEntity.status(res.getStatusCode()).body(res.bodyTo(String.class))); + + assertThat(callbackRes.getStatusCode()).isEqualTo(HttpStatus.CREATED); + JsonNode conn = JSON.readTree(callbackRes.getBody()); + assertThat(conn.get("provider").asText()).isEqualTo("JIRA"); + assertThat(conn.get("credentialType").asText()).isEqualTo("OAUTH2"); + assertThat(conn.get("siteUrl").asText()).isEqualTo("https://acme.atlassian.net"); + assertThat(conn.hasNonNull("email")).isFalse(); // email is null for OAUTH2 + assertThat(callbackRes.getBody()).doesNotContain("access-token-1"); + assertThat(callbackRes.getBody()).doesNotContain("refresh-token-1"); + String connectionId = conn.get("id").asText(); + + // The stored OAuth tokens are ciphertext (BYTEA), not the plaintext values; cloud_id + type persist. + Map row = jdbcTemplate.queryForMap( + "SELECT credential_type, cloud_id, email, secret_ciphertext, " + + "encode(oauth_refresh_ciphertext, 'escape') AS refresh_txt, " + + "encode(oauth_access_ciphertext, 'escape') AS access_txt " + + "FROM \"" + schema + "\".integration_connections WHERE id = ?::uuid", connectionId); + assertThat(row.get("credential_type")).isEqualTo("OAUTH2"); + assertThat(row.get("cloud_id")).isEqualTo("cloud-1"); + assertThat(row.get("email")).isNull(); + assertThat(row.get("secret_ciphertext")).isNull(); + assertThat((String) row.get("refresh_txt")).doesNotContain("refresh-token-1"); + assertThat((String) row.get("access_txt")).doesNotContain("access-token-1"); + + // 3. Seed a story, set the target, push -> routes to the OAuth API base. + String storyId = seedStory(projectId, orgId); + setTarget(projectId, orgId, connectionId); + recordingJiraClient.apiBases.clear(); + + ResponseEntity pushRes = client().post() + .uri("/api/projects/{p}/integration/jira/stories/{s}/push", projectId, storyId) + .header("Authorization", TestJwtFactory.bearer(USER_ID, orgId, "ROLE_USER")) + .header("Api-Version", "1") + .exchange((req, res) -> ResponseEntity.status(res.getStatusCode()).body(res.bodyTo(String.class))); + + assertThat(pushRes.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode push = JSON.readTree(pushRes.getBody()); + assertThat(push.get("jiraIssueKey").asText()).isEqualTo("PAY-42"); + // The push routed through the OAuth base, not the API-token site base. + assertThat(recordingJiraClient.apiBases) + .allSatisfy(base -> assertThat(base).isEqualTo("https://api.atlassian.com/ex/jira/cloud-1/rest/api/3")); + } + + private String seedStory(UUID projectId, String orgId) throws Exception { + ResponseEntity storyRes = client().post().uri("/api/projects/{p}/stories", projectId) + .header("Authorization", TestJwtFactory.bearer(USER_ID, orgId, "ROLE_USER")) + .header("Api-Version", "1") + .contentType(MediaType.APPLICATION_JSON) + .body(Map.of("title", "OAuth push", "role", "analyst", + "action", "push via oauth", "benefit", "no api token", "priority", "HIGH")) + .exchange((req, res) -> ResponseEntity.status(res.getStatusCode()).body(res.bodyTo(String.class))); + assertThat(storyRes.getStatusCode()).isEqualTo(HttpStatus.CREATED); + return JSON.readTree(storyRes.getBody()).get("id").asText(); + } + + private void setTarget(UUID projectId, String orgId, String connectionId) { + ResponseEntity targetRes = client().put() + .uri("/api/projects/{p}/integration/jira/target", projectId) + .header("Authorization", TestJwtFactory.bearer(USER_ID, orgId, "ROLE_USER")) + .header("Api-Version", "1") + .contentType(MediaType.APPLICATION_JSON) + .body(Map.of("connectionId", connectionId, "jiraProjectKey", "PAY", "issueTypeName", "Story")) + .exchange((req, res) -> ResponseEntity.status(res.getStatusCode()).body(res.bodyTo(String.class))); + assertThat(targetRes.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + private UUID createProject(String orgId, String schema, String name) { + client().post().uri("/api/organizations/{orgId}/projects", orgId) + .header("Authorization", TestJwtFactory.bearer(USER_ID, orgId, "ROLE_USER")) + .header("Api-Version", "1") + .contentType(MediaType.APPLICATION_JSON) + .body(Map.of("name", name, "programmingLanguages", List.of("Java"), + "frameworks", List.of("Spring Boot"), "clientPlatforms", List.of("Web"), + "databases", List.of("PostgreSQL"), "architecture", "Hexagonal", "domain", "Fintech")) + .exchange((req, res) -> ResponseEntity.status(res.getStatusCode()).body(res.bodyTo(String.class))); + return UUID.fromString(jdbcTemplate.queryForObject( + "SELECT id::text FROM \"" + schema + "\".projects WHERE name = ?", String.class, name)); + } + + private String createOrg(String suffix, String expectedSlug) { + ResponseEntity orgRes = client().post().uri("/api/organizations") + .header("Authorization", TestJwtFactory.bearer(USER_ID, UUID.randomUUID().toString(), "ROLE_USER")) + .header("Api-Version", "1") + .contentType(MediaType.APPLICATION_JSON) + .body(Map.of("name", "OAuth Org " + suffix)) + .exchange((req, res) -> ResponseEntity.status(res.getStatusCode()).body(res.bodyTo(String.class))); + assertThat(orgRes.getStatusCode()).isEqualTo(HttpStatus.CREATED); + return jdbcTemplate.queryForObject( + "SELECT id FROM public.organizations WHERE slug = ?", String.class, expectedSlug); + } +} From 49fd52fd6d5a2f78a407c3497400a1a9047a7eee Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Mon, 6 Jul 2026 20:41:52 -0500 Subject: [PATCH 35/72] docs(gateway): document jira oauth 2.0 3lo in adr-0022 and changelog record the shipped oauth flow (credentialType discriminator, dual-mode base url + auth, token refresh/rotation, stateless signed state, single-use-code caching, V23 migration, optional config) and the response contract change (credentialType added, email nullable for oauth) plus the new error codes. --- CHANGELOG.md | 28 +++++++++++++ .../adr/0023-third-party-integrations-jira.md | 42 +++++++++++++++---- 2 files changed, 61 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d1ff63c0..dca732ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,34 @@ _Bounded-context implementation (iam, billing, workspace, discovery, gateway) in per project). New error codes: `INTEGRATION_CONNECTION_NOT_FOUND`, `INTEGRATION_ALREADY_CONNECTED`, `INTEGRATION_TARGET_NOT_CONFIGURED`, `JIRA_PROJECT_NOT_FOUND`, `JIRA_AUTH_FAILED`, `JIRA_UNREACHABLE`, `JIRA_PUSH_FAILED`, `INTEGRATION_ENCRYPTION_ERROR`. + - **Jira OAuth 2.0 (3LO) as a second credential type** (ADR-0022) — added alongside the API-token + flow, which is unchanged. A `credentialType` (`API_TOKEN` | `OAUTH2`) selects the auth: OAuth uses + bearer auth against `https://api.atlassian.com/ex/jira/{cloudId}/rest/api/3`, API tokens keep basic + auth against `https://{site}/rest/api/3`. **`IntegrationConnectionResponse` now carries + `credentialType`, and `email` is `null` for `OAUTH2` connections** (frontend contract change). No + token or ciphertext is ever returned. + - **New org-admin-gated endpoints** (header `Api-Version: 1`): + `GET /organizations/{orgId}/integrations/jira/oauth/authorize-url` → `{url, state}` (the `state` + is a stateless HMAC-signed token over org+user+expiry+nonce; `501 JIRA_OAUTH_NOT_CONFIGURED` when + OAuth is unconfigured), and + `POST /organizations/{orgId}/integrations/jira/oauth/callback` `{code, state, cloudId?}` — validates + the state, exchanges the code, and either saves an encrypted `OAUTH2` connection (cloudId given or + exactly one accessible site) or returns `200 {sites:[…]}` to choose from (multiple sites), enforcing + the one-active-connection rule (`409 INTEGRATION_ALREADY_CONNECTED`). Authorization codes are + single-use, so the exchanged tokens are cached under the `state` (short TTL) and the site-selection + re-POST completes from the cache without re-exchanging the code. + - **Token handling** — OAuth refresh + access tokens are encrypted at rest with the same AES-256-GCM + `SecretCipher`; before an OAuth call the access token is refreshed if near expiry and the **rotated** + refresh token is persisted (`JIRA_AUTH_FAILED` on refresh failure). + - **Config** (all optional; the app boots when unset): `reqsai.integrations.jira.oauth.client-id`, + `client-secret`, `redirect-uri` (from `JIRA_OAUTH_CLIENT_ID` / `JIRA_OAUTH_CLIENT_SECRET` / + `JIRA_OAUTH_CALLBACK_URL`) and a dedicated `state-secret` (`JIRA_OAUTH_STATE_SECRET`; generate with + `scripts/generate-oauth-state-secret.sh`). + - Migration `V23__integration_connections_oauth.sql` (tenant, additive): adds `credential_type` + (default `API_TOKEN`), `cloud_id`, `oauth_refresh_ciphertext`, `oauth_access_ciphertext`, + `oauth_access_expires_at`, and relaxes `email` + `secret_ciphertext` to nullable. New error codes: + `JIRA_OAUTH_NOT_CONFIGURED` (501), `JIRA_OAUTH_STATE_INVALID` (400), + `JIRA_OAUTH_EXCHANGE_FAILED` (502). ### Added (Backlog / Glossary / Constraints listing — `feature/discovery-session-control`) diff --git a/docs/adr/0023-third-party-integrations-jira.md b/docs/adr/0023-third-party-integrations-jira.md index c6d39dfd..a3c0b3f9 100644 --- a/docs/adr/0023-third-party-integrations-jira.md +++ b/docs/adr/0023-third-party-integrations-jira.md @@ -82,16 +82,39 @@ The push/verify capability is expressed as an `IntegrationProvider` port in the endpoints or the aggregates. The `JiraClient` mirrors the existing `AssemblyAiAdapter` RestClient style (per-call `RestClient`, typed response records, status→exception mapping). -### API token now, OAuth 2.0 (3LO) later +### API token and OAuth 2.0 (3LO), side by side -Authentication today is Jira **basic auth with an API token**: +Authentication started as Jira **basic auth with an API token**: `Authorization: Basic base64(email:token)`, base URL `https://{site}/rest/api/3/...`. The credential abstraction (`IntegrationConnection` carrying an encrypted secret + the `IntegrationProvider` seam) -is deliberately auth-mechanism-agnostic: adding OAuth 2.0 (3LO) later means storing an OAuth -refresh/access token in the same encrypted secret column (or a sibling column), adding a -`credentialType` discriminator, and having `JiraProvider` build an `Authorization: Bearer` header -instead of `Basic` — the endpoints, RBAC, target model and push flow are unchanged. No OAuth code -ships now; the seam is what ships. +is deliberately auth-mechanism-agnostic, and we have now added **OAuth 2.0 (3LO)** alongside it — the +API-token flow is unchanged. + +**Update (OAuth 2.0 3LO shipped).** A `credentialType` discriminator (`API_TOKEN` | `OAUTH2`) selects +the credential shape on the same `integration_connections` table (migration `V23`, additive): OAuth +rows carry the Atlassian `cloud_id` and the **encrypted** refresh/access tokens (`oauth_refresh_ciphertext` +/ `oauth_access_ciphertext`, same `SecretCipher`/`AesGcmCipher` as the API token) plus +`oauth_access_expires_at`, while `email` + `secret_ciphertext` are relaxed to nullable and left empty. +Exactly one shape is populated per `credentialType` (app-enforced). The **same** `JiraProvider`/`JiraClient` +serve both modes: the base URL + `Authorization` header are chosen per call — `https://{site}/rest/api/3` ++ `Basic` for API tokens, `https://api.atlassian.com/ex/jira/{cloudId}/rest/api/3` + `Bearer` for OAuth. +Before an OAuth call, `JiraOAuthTokenService` refreshes the access token if it is expired/near-expiry and +persists the **rotated** tokens (a refresh failure surfaces as `JIRA_AUTH_FAILED`). + +Two new org-admin-gated endpoints drive the flow, keyed off a **stateless HMAC-signed `state`** (over +org + user + short expiry + nonce, using a dedicated `JIRA_OAUTH_STATE_SECRET`) so nothing is stored to +survive the browser redirect: +`GET /organizations/{orgId}/integrations/jira/oauth/authorize-url` → `{url, state}`, and +`POST /organizations/{orgId}/integrations/jira/oauth/callback` `{code, state, cloudId?}` which validates +the state, exchanges the code, and either saves an `OAUTH2` connection (cloudId given or exactly one +accessible site) or returns the site list to choose from (multiple sites). Because authorization codes +are **single-use**, the callback caches the exchanged tokens + discovered sites under the `state` (short +in-memory TTL) so the follow-up site-selection POST completes from the cache without re-exchanging the +already-consumed code. OAuth config is **optional**: when the client id/secret/redirect are absent the +app still boots and the endpoints answer `JIRA_OAUTH_NOT_CONFIGURED` (501) so the UI disables the button. +New error codes: `JIRA_OAUTH_NOT_CONFIGURED` (501), `JIRA_OAUTH_STATE_INVALID` (400), +`JIRA_OAUTH_EXCHANGE_FAILED` (502). `IntegrationConnectionResponse` now carries `credentialType`, and +`email` is `null` for OAuth connections; no token or ciphertext is ever returned. ### Encryption at rest (AES-256-GCM) @@ -126,9 +149,10 @@ org-admin action, not a project permission. IAM identity/authn is untouched. - Domain: `IntegrationsError` — `INTEGRATION_CONNECTION_NOT_FOUND` (404), `INTEGRATION_ALREADY_CONNECTED` (409), `INTEGRATION_TARGET_NOT_CONFIGURED` (409), - `JIRA_PROJECT_NOT_FOUND` (404). + `JIRA_PROJECT_NOT_FOUND` (404), `JIRA_OAUTH_NOT_CONFIGURED` (501), `JIRA_OAUTH_STATE_INVALID` (400). - Infrastructure: `IntegrationsInfrastructureError` — `JIRA_AUTH_FAILED` (401), - `JIRA_UNREACHABLE` (502), `JIRA_PUSH_FAILED` (502), `INTEGRATION_ENCRYPTION_ERROR` (500). + `JIRA_UNREACHABLE` (502), `JIRA_PUSH_FAILED` (502), `INTEGRATION_ENCRYPTION_ERROR` (500), + `JIRA_OAUTH_EXCHANGE_FAILED` (502). Both are `ErrorCatalog` enums auto-mapped by the shared `GlobalExceptionHandler`; infrastructure errors never leak the token or the internal cause to the client. From 7d4a5c1137295c73aa507474596a70f6e61a6f4d Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Mon, 6 Jul 2026 20:45:07 -0500 Subject: [PATCH 36/72] test(gateway): align oauth integration test org slug with its schema --- .../gateway/interfaces/rest/JiraOAuthIntegrationTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/com/kntro/reqsai/gateway/interfaces/rest/JiraOAuthIntegrationTest.java b/src/test/java/com/kntro/reqsai/gateway/interfaces/rest/JiraOAuthIntegrationTest.java index bd51458e..a032ba3e 100644 --- a/src/test/java/com/kntro/reqsai/gateway/interfaces/rest/JiraOAuthIntegrationTest.java +++ b/src/test/java/com/kntro/reqsai/gateway/interfaces/rest/JiraOAuthIntegrationTest.java @@ -160,7 +160,7 @@ private String createOrg(String suffix, String expectedSlug) { .header("Authorization", TestJwtFactory.bearer(USER_ID, UUID.randomUUID().toString(), "ROLE_USER")) .header("Api-Version", "1") .contentType(MediaType.APPLICATION_JSON) - .body(Map.of("name", "OAuth Org " + suffix)) + .body(Map.of("name", "Oauth " + suffix)) .exchange((req, res) -> ResponseEntity.status(res.getStatusCode()).body(res.bodyTo(String.class))); assertThat(orgRes.getStatusCode()).isEqualTo(HttpStatus.CREATED); return jdbcTemplate.queryForObject( From bc9e72c7930704289d17a70694fd88ab400b23b7 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Mon, 6 Jul 2026 20:51:11 -0500 Subject: [PATCH 37/72] docs(env): document integrations encryption key and jira oauth vars in .env.example --- .env.example | 14 ++++++++++++++ src/main/resources/application-dev.yml | 2 -- src/main/resources/application.yml | 5 ----- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/.env.example b/.env.example index f6daa1f4..8c0fc838 100644 --- a/.env.example +++ b/.env.example @@ -145,3 +145,17 @@ JWT_ISSUER=reqsai # Optional: Override display amounts in cents (must match Stripe Price object) # BILLING_PRO_AMOUNT_CENTS=2900 # BILLING_ENTERPRISE_AMOUNT_CENTS=9900 + +# ── Integrations — Jira (gateway) ───────────────────────────────────────────── +# INTEGRATIONS_ENCRYPTION_KEY=your-base64-32-byte-key-here + +# Jira OAuth 2.0 (3LO) — OPTIONAL. Without it the API-token flow still works and +# the "Connect with Atlassian" button reports not-configured. +# Register an app at https://developer.atlassian.com/console/myapps (OAuth 2.0, +# Jira API scopes: read:jira-work write:jira-work read:jira-user offline_access read:me). +# JIRA_OAUTH_CALLBACK_URL must match the app's Authorization → Callback URL exactly. +# JIRA_OAUTH_CLIENT_ID=your-atlassian-oauth-client-id +# JIRA_OAUTH_CLIENT_SECRET=your-atlassian-oauth-client-secret +# JIRA_OAUTH_CALLBACK_URL=http://localhost:4200/settings/integrations/jira/callback +# Generate: scripts/generate-oauth-state-secret.sh (openssl rand -hex 32) +# JIRA_OAUTH_STATE_SECRET=your-hex-64-char-state-secret diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 55968a31..fd4c0398 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -30,8 +30,6 @@ spring: reqsai: integrations: - # Local-only default AES-256 key (base64 of bytes 0..31) so the app boots without a .env in dev. - # Override with a real INTEGRATIONS_ENCRYPTION_KEY anywhere it matters. NEVER use this in prod. encryption-key: ${INTEGRATIONS_ENCRYPTION_KEY:AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=} jira: oauth: diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 019fd222..fcc7e6db 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -191,14 +191,9 @@ reqsai: discovery: realtime: - # Char threshold: generate once this many NEW (past-watermark) transcript chars have accrued. min-transcript-chars: ${DISCOVERY_REALTIME_MIN_TRANSCRIPT_CHARS:180} - # Time fallback: generate when this many seconds have elapsed since the last pass with new - # transcript waiting, even if the char threshold has not been reached (short exchanges stream). max-transcript-age-seconds: ${DISCOVERY_REALTIME_MAX_TRANSCRIPT_AGE_SECONDS:22} context-top-k: ${DISCOVERY_REALTIME_CONTEXT_TOP_K:5} - # Cross-pass near-duplicate cosine threshold: drop a draft this similar to a PENDING suggestion - # or existing story before persisting (below the 0.85 duplicate-story threshold to catch paraphrases). dedup-similarity-threshold: ${DISCOVERY_REALTIME_DEDUP_SIMILARITY_THRESHOLD:0.84} server: From d2e5f84bb2b843564ea6033464083420b8fde3e2 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Mon, 6 Jul 2026 21:01:52 -0500 Subject: [PATCH 38/72] chore(scripts): add integrations encryption key generator Mirrors generate-oauth-state-secret.sh but emits a base64-encoded 32-byte AES-256 key for INTEGRATIONS_ENCRYPTION_KEY (hex would fail at startup). --- .env.example | 6 ------ scripts/generate-encryption-key.sh | 19 +++++++++++++++++++ 2 files changed, 19 insertions(+), 6 deletions(-) create mode 100644 scripts/generate-encryption-key.sh diff --git a/.env.example b/.env.example index 8c0fc838..ed909bbf 100644 --- a/.env.example +++ b/.env.example @@ -148,14 +148,8 @@ JWT_ISSUER=reqsai # ── Integrations — Jira (gateway) ───────────────────────────────────────────── # INTEGRATIONS_ENCRYPTION_KEY=your-base64-32-byte-key-here - -# Jira OAuth 2.0 (3LO) — OPTIONAL. Without it the API-token flow still works and -# the "Connect with Atlassian" button reports not-configured. -# Register an app at https://developer.atlassian.com/console/myapps (OAuth 2.0, -# Jira API scopes: read:jira-work write:jira-work read:jira-user offline_access read:me). # JIRA_OAUTH_CALLBACK_URL must match the app's Authorization → Callback URL exactly. # JIRA_OAUTH_CLIENT_ID=your-atlassian-oauth-client-id # JIRA_OAUTH_CLIENT_SECRET=your-atlassian-oauth-client-secret # JIRA_OAUTH_CALLBACK_URL=http://localhost:4200/settings/integrations/jira/callback -# Generate: scripts/generate-oauth-state-secret.sh (openssl rand -hex 32) # JIRA_OAUTH_STATE_SECRET=your-hex-64-char-state-secret diff --git a/scripts/generate-encryption-key.sh b/scripts/generate-encryption-key.sh new file mode 100644 index 00000000..0e0ba421 --- /dev/null +++ b/scripts/generate-encryption-key.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Generates a random AES-256 key for encrypting integration secrets at rest (ADR-0022). +# - 32 random bytes, base64-encoded (~44 chars), used as the AES-256-GCM key. +# +# NOTE: this is base64 (NOT hex) — the backend base64-decodes it and requires exactly +# 32 bytes, so a hex value would fail at startup. The Jira OAuth state secret is a +# DIFFERENT value in a DIFFERENT format (hex) — see scripts/generate-oauth-state-secret.sh. +# +# The key is NOT stored in the repo. Paste the printed value into your .env as +# INTEGRATIONS_ENCRYPTION_KEY; in production mount it as a secret (see the deploy workflow). +set -euo pipefail + +KEY="$(openssl rand -base64 32)" + +echo "Generated integrations encryption key (AES-256):" +echo +echo "INTEGRATIONS_ENCRYPTION_KEY=$KEY" +echo +echo "Paste the line above into your .env (do not commit it)." From 7adfb9bdd7b6015ca0ecc1dfbcd472c4d454995b Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Mon, 6 Jul 2026 21:06:50 -0500 Subject: [PATCH 39/72] docs(gateway): add jira integration setup guide (scopes, callback, secrets, scripts) --- README.md | 1 + docs/JIRA_INTEGRATION.md | 156 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 docs/JIRA_INTEGRATION.md diff --git a/README.md b/README.md index de43aff5..5357aae9 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,7 @@ Detalle en [`docs/DEPLOYMENT.md`](./docs/DEPLOYMENT.md). | [`docs/LOCAL_AI.md`](./docs/LOCAL_AI.md) | IA local↔nube (LLM, embeddings, STT) — Mac/Win/Linux | | [`docs/DEPLOYMENT.md`](./docs/DEPLOYMENT.md) | Despliegue (Docker, AWS ECS Fargate, CI/CD) | | [`docs/MIGRATIONS.md`](./docs/MIGRATIONS.md) | Cómo crear migraciones Flyway (`scripts/new-migration.sh`) | +| [`docs/JIRA_INTEGRATION.md`](./docs/JIRA_INTEGRATION.md) | Integración Jira: permisos, callback, secretos, uso | | [`.github/CONTRIBUTING.md`](./.github/CONTRIBUTING.md) | Flujo de trabajo, build, tests, ramas, commits | | [`CHANGELOG.md`](./CHANGELOG.md) | Historial de cambios (Keep a Changelog) | | [`AUTHORS.md`](./AUTHORS.md) · [`CONTRIBUTORS.md`](./CONTRIBUTORS.md) · [`ACKNOWLEDGMENTS.md`](./ACKNOWLEDGMENTS.md) | Equipo y créditos | diff --git a/docs/JIRA_INTEGRATION.md b/docs/JIRA_INTEGRATION.md new file mode 100644 index 00000000..732a4bbb --- /dev/null +++ b/docs/JIRA_INTEGRATION.md @@ -0,0 +1,156 @@ +# Jira integration — setup guide + +Reqs-AI can push **user stories to Jira Cloud** as issues. The integration lives in the **`gateway`** +bounded context and is designed to be provider-extensible (Jira is the first provider). See +[ADR-0022](adr/0022-third-party-integrations-jira.md) for the design rationale. + +- **Connection is organization-level** — credentials are stored once per org, encrypted at rest. +- **Push target is project-level** — each project picks which Jira project + issue type its stories go to. +- **Two ways to connect**, pick either (both can coexist): + +| Method | User experience | What you must set up | Best for | +|---------------------|-----------------------------------------------|---------------------------------------------------------|------------------------------------------------| +| **OAuth 2.0 (3LO)** | One click "Connect with Atlassian", no typing | Register an Atlassian app (Client ID/Secret + callback) | The recommended, smoothest flow | +| **API token** | Paste site URL + email + API token | Nothing server-side; each user creates a token | Quick start, or when you can't register an app | + +> The Jira API token / OAuth tokens are **never** stored in the browser and never returned by the API. +> They are encrypted at rest with AES-256-GCM. + +--- + +## 1. Generate the backend secrets + +Two secrets are needed. They are **different values in different formats** — do not mix them up. + +| Env var | Purpose | Format | Script | +|-------------------------------|------------------------------------------|------------------------------------------------------------|------------------------------------------| +| `INTEGRATIONS_ENCRYPTION_KEY` | Encrypt stored credentials (AES-256-GCM) | **base64**, decodes to 32 bytes (~44 chars, ends with `=`) | `scripts/generate-encryption-key.sh` | +| `JIRA_OAUTH_STATE_SECRET` | Sign the OAuth `state` (HMAC-SHA256) | **hex**, 64 chars (`0-9 a-f`) | `scripts/generate-oauth-state-secret.sh` | + +Run them in **Git Bash** (they use `openssl`, bundled with Git for Windows): + +```bash +bash scripts/generate-encryption-key.sh # prints INTEGRATIONS_ENCRYPTION_KEY=... +bash scripts/generate-oauth-state-secret.sh # prints JIRA_OAUTH_STATE_SECRET=... +``` + +Each prints one line to the terminal — copy it into your `.env`. Nothing is written to the repo. + +> **Common mistake:** using the hex value for `INTEGRATIONS_ENCRYPTION_KEY`. A 64-char hex string +> base64-decodes to 48 bytes, and the app fails at startup with +> `INTEGRATIONS_ENCRYPTION_KEY must decode to 32 bytes (AES-256), got 48`. The encryption key **must** +> come from `generate-encryption-key.sh` (base64). Verify with: +> `echo -n "" | base64 -d | wc -c` → must print `32`. + +`INTEGRATIONS_ENCRYPTION_KEY` is **required** to run the integration (dev/test carry a default in +`application-dev.yml` / `application-test.yml`). The OAuth vars below are **optional** — without them the +API-token flow still works and the "Connect with Atlassian" button reports *not configured*. + +--- + +## 2. Register the Atlassian OAuth app (only for the OAuth method) + +1. Go to **https://developer.atlassian.com/console/myapps** → **Create → OAuth 2.0 integration**. +2. **Name:** e.g. `ReqsAI`. **Access type:** **Resource-level** (least privilege — only the site the + user selects during authorization). Accept the terms → **Create**. +3. **Permissions → Add → Jira API**, and add these scopes (must match what the backend requests): + + ``` + read:jira-work write:jira-work read:jira-user read:me + ``` + + > `offline_access` is **not** added here — the backend requests it at login time to obtain a refresh + > token. The full scope string the backend sends is + > `read:jira-work write:jira-work read:jira-user offline_access read:me`. If the console has *fewer* + > scopes than this, Atlassian rejects authorization with *invalid scope*. + +4. **Authorization → OAuth 2.0 (3LO) → Configure** → set the **Callback URL** (see §3). Save. +5. **Settings → copy the Client ID and Secret** → these become `JIRA_OAUTH_CLIENT_ID` / + `JIRA_OAUTH_CLIENT_SECRET`. +6. Leave **Distribution = private** for development (only your Atlassian account can authorize it). + Switch to *Sharing* only when other organizations must connect their own Jira (production) — that + step asks for vendor name, privacy policy and a personal-data declaration. + +### The Callback URL + +It is **not** something Atlassian gives you — **you define it**, and it must match, character for +character, both the Atlassian app's *Authorization → Callback URL* and the backend's +`JIRA_OAUTH_CALLBACK_URL`. It is the frontend route that receives the OAuth redirect: + +| Environment | Callback URL | +|-------------|--------------------------------------------------------------------| +| Local dev | `http://localhost:4200/settings/integrations/jira/callback` | +| Production | `https://YOUR-FRONTEND-DOMAIN/settings/integrations/jira/callback` | + +You may register **multiple** callback URLs (one per line, up to 30). Order does not matter — the +backend sends the exact `redirect_uri` for its environment, and Atlassian only requires it to be in the +list. If it doesn't match, Atlassian errors with `redirect_uri mismatch`. + +--- + +## 3. Configure `.env` + +Add these to the backend `.env` (see `.env.example` for the annotated block): + +```dotenv +# Required for the integration (base64, 32 bytes) +INTEGRATIONS_ENCRYPTION_KEY= + +# Optional — only for the OAuth "Connect with Atlassian" flow +JIRA_OAUTH_CLIENT_ID= +JIRA_OAUTH_CLIENT_SECRET= +JIRA_OAUTH_CALLBACK_URL=http://localhost:4200/settings/integrations/jira/callback +JIRA_OAUTH_STATE_SECRET= +``` + +These map to `reqsai.integrations.encryption-key` and `reqsai.integrations.jira.oauth.*`. Restart the +backend after editing `.env`. + +--- + +## 4. Alternative: the API-token method (no app registration) + +Each user creates a personal API token: + +1. Go to **https://id.atlassian.com/manage-profile/security/api-tokens** → **Create API token**. +2. In Reqs-AI → **Org Settings → Integrations → Jira**, expand *"use an API token"* and enter: + - **Site URL** — `https://your-space.atlassian.net` + - **Email** — the Atlassian account email that owns the token + - **API token** — the value you just created (sent encrypted, never shown again) + +--- + +## 5. Use it + +1. **Connect** (once per org): Org Settings → **Integrations** → *Connect with Atlassian* (OAuth) or the + API-token form. On multi-site Atlassian accounts you pick the site. +2. **Map** (per project): Project Settings → **Integrations** → choose the Jira project + issue type. +3. **Push**: from a story's detail (*Push to Jira*) or the backlog (*Push all to Jira*). The story title + becomes the issue summary; the description carries role/action/benefit + acceptance criteria + (Given/When/Then). + +Actions are RBAC-gated by new permissions: `INTEGRATION_READ`, `INTEGRATION_WRITE`, `INTEGRATION_DELETE`, +`INTEGRATION_SYNC`. Org-level connection management requires org owner/admin. + +--- + +## Troubleshooting + +| Symptom | Cause & fix | +|---------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------| +| Startup: `...must decode to 32 bytes (AES-256), got 48` | `INTEGRATIONS_ENCRYPTION_KEY` holds a hex value. Regenerate with `scripts/generate-encryption-key.sh` (base64). | +| Button disabled / `JIRA_OAUTH_NOT_CONFIGURED` | OAuth env vars are missing/blank. Set `JIRA_OAUTH_CLIENT_ID/SECRET/CALLBACK_URL` and restart. | +| Atlassian: `redirect_uri mismatch` | The app's Callback URL ≠ `JIRA_OAUTH_CALLBACK_URL`. Make them identical. | +| Atlassian: `invalid scope` | The console has fewer scopes than the backend requests. Add all of `read:jira-work write:jira-work read:jira-user read:me`. | +| `JIRA_AUTH_FAILED` on connect/push | Bad API token/email, expired OAuth grant, or the token owner lacks access to the Jira project. | +| `INTEGRATION_TARGET_NOT_CONFIGURED` on push | No Jira target set for the project — configure it in Project Settings → Integrations. | + +--- + +## Reference + +- **Design:** [ADR-0022](adr/0022-third-party-integrations-jira.md) +- **Module:** `com.kntro.reqsai.gateway` +- **Migrations (tenant):** `V21` connections, `V22` targets, `V23` OAuth columns +- **Config keys:** `reqsai.integrations.encryption-key`, `reqsai.integrations.jira.oauth.{client-id,client-secret,redirect-uri,state-secret}` +- **Endpoints:** `/api/organizations/{orgId}/integrations*` (connection, OAuth authorize-url/callback), `/api/projects/{projectId}/integration/jira*` (target, story push) From 5cdb8e700a9eb95c22935646149ba53c7eba5d49 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Mon, 6 Jul 2026 21:37:03 -0500 Subject: [PATCH 40/72] feat(workspace): let org admins view organization general settings GET /organizations/{orgId} is now gated by @authz.orgOwnerOrAdmin instead of @authz.orgOwner, so an organization admin can read the org general settings (200 instead of 403). Editing stays owner-only: update, transfer-ownership and delete remain @authz.orgOwner. Non-members still receive 403 on the GET. --- CHANGELOG.md | 8 ++++ .../OrganizationControllerImpl.java | 2 +- .../rest/GetOrganizationIntegrationTest.java | 38 +++++++++++++++- .../UpdateOrganizationIntegrationTest.java | 43 +++++++++++++++++++ 4 files changed, 89 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dca732ab..61d06d35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -91,6 +91,14 @@ _Bounded-context implementation (iam, billing, workspace, discovery, gateway) in `JIRA_OAUTH_NOT_CONFIGURED` (501), `JIRA_OAUTH_STATE_INVALID` (400), `JIRA_OAUTH_EXCHANGE_FAILED` (502). +### Changed (Workspace — `feature/integrations-jira`) + +- **Org admins may now view organization general settings** — `GET /organizations/{orgId}` is now gated + by `@authz.orgOwnerOrAdmin` instead of `@authz.orgOwner`, so an organization **admin** receives `200` + when reading the org (previously `403`). Editing stays owner-only: `PATCH /organizations/{orgId}`, + `POST /organizations/{orgId}/transfer-ownership`, and `DELETE /organizations/{orgId}` remain + `@authz.orgOwner`. Non-members still receive `403` on the GET. + ### Added (Backlog / Glossary / Constraints listing — `feature/discovery-session-control`) - **User-story backlog list filters + search** — `GET /projects/{projectId}/stories` now accepts five diff --git a/src/main/java/com/kntro/reqsai/workspace/interfaces/rest/controllers/OrganizationControllerImpl.java b/src/main/java/com/kntro/reqsai/workspace/interfaces/rest/controllers/OrganizationControllerImpl.java index cb96f9e2..7aa5128f 100644 --- a/src/main/java/com/kntro/reqsai/workspace/interfaces/rest/controllers/OrganizationControllerImpl.java +++ b/src/main/java/com/kntro/reqsai/workspace/interfaces/rest/controllers/OrganizationControllerImpl.java @@ -50,7 +50,7 @@ public ResponseEntity> list(Authentication authentica } @Override - @PreAuthorize("@authz.orgOwner(#orgId, authentication)") + @PreAuthorize("@authz.orgOwnerOrAdmin(#orgId, authentication)") public ResponseEntity getById(UUID orgId, Authentication authentication) { UUID requestedBy = UUID.fromString(authentication.getName()); Organization organization = getOrganization.handle(new GetOrganizationQuery(orgId, requestedBy)); diff --git a/src/test/java/com/kntro/reqsai/workspace/interfaces/rest/GetOrganizationIntegrationTest.java b/src/test/java/com/kntro/reqsai/workspace/interfaces/rest/GetOrganizationIntegrationTest.java index 5bfa5bcb..f43f5058 100644 --- a/src/test/java/com/kntro/reqsai/workspace/interfaces/rest/GetOrganizationIntegrationTest.java +++ b/src/test/java/com/kntro/reqsai/workspace/interfaces/rest/GetOrganizationIntegrationTest.java @@ -25,6 +25,7 @@ class GetOrganizationIntegrationTest extends AbstractIntegrationTest { private static final String USER_ID = "00000000-0000-0000-0000-000000000001"; + private static final String ADMIN_USER_ID = "00000000-0000-0000-0000-000000000003"; private static final String ORG_ID = "00000000-0000-0000-0000-000000000009"; @Autowired @@ -55,7 +56,31 @@ void should_return_the_organization_for_its_owner() { } @Test - @DisplayName("should reject get from a non-owner") + @DisplayName("should return the organization for an org admin") + void should_return_the_organization_for_an_admin() { + String suffix = UUID.randomUUID().toString().substring(0, 8); + String expectedSlug = "acme-" + suffix; + + ResponseEntity createResponse = post( + Map.of("name", "Acme " + suffix, "meetingLanguage", "en-US"), + TestJwtFactory.bearer(USER_ID, ORG_ID, "ROLE_USER")); + assertThat(createResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED); + + String organizationId = extractOrganizationId(expectedSlug); + createMember(organizationId, USER_ID, Map.of( + "userId", ADMIN_USER_ID, "email", "admin@example.com", "displayName", "Admin", "role", "ADMIN")); + + ResponseEntity getResponse = get( + organizationId, + TestJwtFactory.bearer(ADMIN_USER_ID, organizationId, "ROLE_USER")); + + assertThat(getResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(getResponse.getBody()).contains("\"slug\":\"" + expectedSlug + "\""); + assertThat(getResponse.getBody()).contains("\"ownerId\":\"" + USER_ID + "\""); + } + + @Test + @DisplayName("should reject get from a non-member") void should_reject_get_from_non_owner() { String suffix = UUID.randomUUID().toString().substring(0, 8); String expectedSlug = "acme-" + suffix; @@ -121,4 +146,15 @@ private ResponseEntity getAnonymously(String organizationId) { .exchange((request, response) -> ResponseEntity.status(response.getStatusCode()) .body(response.bodyTo(String.class)), false); } + + private void createMember(String organizationId, String ownerUserId, Map body) { + ResponseEntity res = client().post().uri("/api/organizations/{orgId}/members", organizationId) + .header("Authorization", TestJwtFactory.bearer(ownerUserId, organizationId, "ROLE_USER")) + .header("Api-Version", "1") + .contentType(MediaType.APPLICATION_JSON) + .body(body) + .exchange((request, response) -> ResponseEntity.status(response.getStatusCode()) + .body(response.bodyTo(String.class))); + assertThat(res.getStatusCode()).isEqualTo(HttpStatus.CREATED); + } } diff --git a/src/test/java/com/kntro/reqsai/workspace/interfaces/rest/UpdateOrganizationIntegrationTest.java b/src/test/java/com/kntro/reqsai/workspace/interfaces/rest/UpdateOrganizationIntegrationTest.java index a99f8eab..b5c16e91 100644 --- a/src/test/java/com/kntro/reqsai/workspace/interfaces/rest/UpdateOrganizationIntegrationTest.java +++ b/src/test/java/com/kntro/reqsai/workspace/interfaces/rest/UpdateOrganizationIntegrationTest.java @@ -227,6 +227,49 @@ void should_reject_unauthenticated_update_request() { assertThat(updateResponse.getStatusCode()).isIn(HttpStatus.UNAUTHORIZED, HttpStatus.FORBIDDEN); } + @Test + @DisplayName("should reject update from an org admin (owner-only edit)") + void should_reject_update_from_an_admin() { + String suffix = UUID.randomUUID().toString().substring(0, 8); + String originalName = "Acme " + suffix; + String expectedSlug = "acme-" + suffix; + String adminUserId = "00000000-0000-0000-0000-000000000003"; + + ResponseEntity createResponse = post( + Map.of("name", originalName, "meetingLanguage", "en-US"), + TestJwtFactory.bearer(USER_ID, ORG_ID, "ROLE_USER")); + assertThat(createResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED); + + String organizationId = jdbcTemplate.queryForObject( + "SELECT id::text FROM public.organizations WHERE slug = ?", String.class, expectedSlug); + createMember(organizationId, USER_ID, Map.of( + "userId", adminUserId, "email", "admin@example.com", "displayName", "Admin", "role", "ADMIN")); + + ResponseEntity updateResponse = patch( + organizationId, + Map.of("name", "Admin Update " + suffix, "meetingLanguage", "pt-BR", "audioRetentionDays", 7), + TestJwtFactory.bearer(adminUserId, organizationId, "ROLE_USER")); + + assertThat(updateResponse.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + + Map row = jdbcTemplate.queryForMap( + "SELECT name, meeting_language FROM public.organizations WHERE id = ?::uuid", + organizationId); + assertThat(row.get("name")).isEqualTo(originalName); + assertThat(row.get("meeting_language")).isEqualTo("en-US"); + } + + private void createMember(String organizationId, String ownerUserId, Map body) { + ResponseEntity res = client().post().uri("/api/organizations/{orgId}/members", organizationId) + .header("Authorization", TestJwtFactory.bearer(ownerUserId, organizationId, "ROLE_USER")) + .header("Api-Version", "1") + .contentType(MediaType.APPLICATION_JSON) + .body(body) + .exchange((request, response) -> ResponseEntity.status(response.getStatusCode()) + .body(response.bodyTo(String.class))); + assertThat(res.getStatusCode()).isEqualTo(HttpStatus.CREATED); + } + private ResponseEntity post(Map body, String bearer) { return client().post().uri("/api/organizations") .header("Authorization", bearer) From c708105957ebffe410bbea6c7736835da9176aec Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Mon, 6 Jul 2026 21:47:50 -0500 Subject: [PATCH 41/72] fix(gateway): create Jira issues by project issue-type id and surface Jira errors Two live-testing defects in the Jira push/issue-type path: - Push failed with JIRA_PUSH_FAILED "Jira returned no issue key" (and 400s said only "Jira rejected the request (400)"). Root cause: the create sent issuetype:{name}. Team-managed/next-gen projects and localized types (e.g. Spanish "Historia") require the issue type by id for that specific project. createIssue now resolves the mapped issue-type NAME to its project-scoped id via GET /issue/createmeta/{key}/issuetypes and sends issuetype:{id}. CreatedIssue also captures the returned id. - Errors are now diagnosable: mapError reads Jira's response body and includes its errorMessages + field errors (token-free) in the thrown exception, and the "no key" case reports project/issue-type context. - listIssueTypes returned the GLOBAL /issuetype list (ignoring projectKey), so callers saw duplicate names across projects. It now returns the types valid for the given project (createmeta), deduped by id, each with its id. Also adds the JQL search (/search/jql, token-paginated: nextPageToken + isLast, no total) and an ADF->plain-text reader, both used by Jira import. JiraClient takes an injectable RestClient.Builder so its HTTP boundary is unit-testable with MockRestServiceServer. --- .../infrastructure/jira/JiraAdfReader.java | 71 ++++++ .../infrastructure/jira/JiraClient.java | 211 +++++++++++++++--- .../reqsai/gateway/StubJiraOAuthConfig.java | 3 +- .../infrastructure/jira/JiraClientTest.java | 158 +++++++++++++ 4 files changed, 416 insertions(+), 27 deletions(-) create mode 100644 src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraAdfReader.java create mode 100644 src/test/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraClientTest.java diff --git a/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraAdfReader.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraAdfReader.java new file mode 100644 index 00000000..4042df49 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraAdfReader.java @@ -0,0 +1,71 @@ +package com.kntro.reqsai.gateway.infrastructure.jira; + +import org.jspecify.annotations.Nullable; + +import java.util.List; +import java.util.Map; + +/** + * Minimal inverse of {@link JiraAdfBuilder}: flattens an Atlassian Document Format (ADF) description node + * (Jira Cloud REST v3 returns descriptions as ADF, not plain text) into plain text so the import mapping + * can feed it to the LLM / fallback parser. + * + *

    Walks the {@code content} tree collecting every {@code text} leaf, inserting a newline after each + * block node ({@code paragraph}, {@code heading}, {@code listItem}) and a {@code "- "} bullet marker before + * list items. Unknown node types are traversed for their text children. Returns {@code ""} for a null or + * empty document — never throws, so a malformed description never aborts an import. + */ +public final class JiraAdfReader { + + private JiraAdfReader() { + throw new UnsupportedOperationException("Utility class"); + } + + /** Flattens the ADF {@code doc} map to plain text (empty string when null/blank). */ + public static String toPlainText(@Nullable Map adf) { + if (adf == null || adf.isEmpty()) { + return ""; + } + StringBuilder out = new StringBuilder(); + appendNode(adf, out); + return out.toString().strip(); + } + + @SuppressWarnings("unchecked") + private static void appendNode(Object node, StringBuilder out) { + if (!(node instanceof Map map)) { + return; + } + String type = String.valueOf(map.get("type")); + if ("text".equals(type)) { + Object text = map.get("text"); + if (text != null) { + out.append(text); + } + return; + } + if ("hardBreak".equals(type)) { + out.append('\n'); + return; + } + if ("listItem".equals(type)) { + out.append("- "); + } + Object content = map.get("content"); + if (content instanceof List children) { + for (Object child : children) { + appendNode(child, out); + } + } + if (isBlock(type)) { + out.append('\n'); + } + } + + private static boolean isBlock(String type) { + return switch (type) { + case "paragraph", "heading", "listItem", "blockquote", "codeBlock" -> true; + default -> false; + }; + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraClient.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraClient.java index f88de4c0..80188c83 100644 --- a/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraClient.java +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraClient.java @@ -1,6 +1,8 @@ package com.kntro.reqsai.gateway.infrastructure.jira; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import com.kntro.reqsai.gateway.infrastructure.exception.IntegrationsInfrastructureExceptions; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatusCode; @@ -8,8 +10,12 @@ import org.springframework.stereotype.Component; import org.springframework.web.client.RestClient; +import java.io.IOException; +import java.net.URLEncoder; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; import java.util.Base64; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -27,16 +33,32 @@ *

      *
    • 401/403 → {@code JIRA_AUTH_FAILED}
    • *
    • connect/timeout/5xx → {@code JIRA_UNREACHABLE}
    • - *
    • 400 on create → {@code JIRA_PUSH_FAILED}
    • + *
    • 400 on create → {@code JIRA_PUSH_FAILED} (with Jira's {@code errorMessages}/field {@code errors})
    • *
    + * + *

    Issue types are resolved project-scoped via {@code createmeta/{projectKey}/issuetypes} + * (the new endpoint; the legacy global {@code /issuetype} returns duplicate names across projects and its + * ids are not always accepted by team-managed projects). Issue creation always sends the issue type by + * id (resolved for the specific project), which team-managed / localized projects + * (e.g. Spanish "Historia") require. */ @Component @Slf4j public class JiraClient { private static final String OAUTH_API_BASE = "https://api.atlassian.com/ex/jira/"; + private static final ObjectMapper ERROR_MAPPER = new ObjectMapper(); - private final RestClient restClient = RestClient.create(); + private final RestClient restClient; + + public JiraClient() { + this(RestClient.builder()); + } + + /** Builder-based constructor so tests can bind a {@code MockRestServiceServer} at the HTTP boundary. */ + public JiraClient(RestClient.Builder builder) { + this.restClient = builder.build(); + } /** * The per-call base URL + {@code Authorization} header for a Jira REST v3 call. The {@code browseBase} @@ -67,7 +89,7 @@ public String verify(JiraApiContext ctx) { .header("Authorization", ctx.authHeader()) .accept(MediaType.APPLICATION_JSON) .retrieve() - .onStatus(HttpStatusCode::isError, (req, res) -> mapError(res.getStatusCode(), false)) + .onStatus(HttpStatusCode::isError, (req, res) -> { throw mapError(res, false); }) .body(Myself.class), "verify"); return me != null ? me.displayName() : ""; } @@ -79,29 +101,65 @@ public List listProjects(JiraApiContext ctx) { .header("Authorization", ctx.authHeader()) .accept(MediaType.APPLICATION_JSON) .retrieve() - .onStatus(HttpStatusCode::isError, (req, res) -> mapError(res.getStatusCode(), false)) + .onStatus(HttpStatusCode::isError, (req, res) -> { throw mapError(res, false); }) .body(ProjectSearch.class), "listProjects"); return search == null || search.values() == null ? List.of() : search.values(); } - /** GET /issuetype → the global issue-type list (project param is accepted for parity, unused). */ + /** + * GET {@code /issue/createmeta/{projectKey}/issuetypes} → the issue types valid for that specific + * project, each with its project-scoped id (deduped by id). Fixes the previous behaviour of returning + * the GLOBAL {@code /issuetype} list, which repeated names ("Historia", "Tarea", …) across projects + * and produced ids that team-managed projects reject on create. + */ public List listIssueTypes(JiraApiContext ctx, String projectKey) { - List types = exchange(() -> restClient.get() - .uri(ctx.apiBase() + "/issuetype") + CreateMetaIssueTypes meta = exchange(() -> restClient.get() + .uri(ctx.apiBase() + "/issue/createmeta/" + enc(projectKey) + "/issuetypes?maxResults=200") .header("Authorization", ctx.authHeader()) .accept(MediaType.APPLICATION_JSON) .retrieve() - .onStatus(HttpStatusCode::isError, (req, res) -> mapError(res.getStatusCode(), false)) - .body(ISSUE_TYPE_LIST), "listIssueTypes"); - return types == null ? List.of() : types; + .onStatus(HttpStatusCode::isError, (req, res) -> { throw mapError(res, false); }) + .body(CreateMetaIssueTypes.class), "listIssueTypes"); + if (meta == null || meta.issueTypes() == null) { + return List.of(); + } + Map byId = new LinkedHashMap<>(); + for (JiraIssueType t : meta.issueTypes()) { + if (t.id() != null) { + byId.putIfAbsent(t.id(), t); + } + } + return List.copyOf(byId.values()); + } + + /** + * Resolves the issue type id for {@code issueTypeName} within {@code projectKey}. The + * mapped target stores a human name ("Story" / "Historia"); create requires the project-scoped id. + * Matches by exact name (case-insensitive). Throws {@code JIRA_PUSH_FAILED} listing the available types + * when the name is not valid for the project. + */ + public String resolveIssueTypeId(JiraApiContext ctx, String projectKey, String issueTypeName) { + List types = listIssueTypes(ctx, projectKey); + return types.stream() + .filter(t -> t.name() != null && t.name().equalsIgnoreCase(issueTypeName)) + .map(JiraIssueType::id) + .findFirst() + .orElseThrow(() -> IntegrationsInfrastructureExceptions.jiraPushFailed( + "issue type '" + issueTypeName + "' is not available in project '" + projectKey + + "' (available: " + types.stream().map(JiraIssueType::name).toList() + ")")); } - /** POST /issue → the created issue's key + self URL. */ + /** + * POST /issue → the created issue's id/key/self. Sends {@code issuetype:{id:…}} (resolved for the + * project) so team-managed and localized projects accept the create. Reads Jira's error body on failure + * and surfaces its {@code errorMessages}/field {@code errors} (token-free) for diagnosability. + */ public CreatedIssue createIssue(JiraApiContext ctx, String projectKey, String issueTypeName, String summary, Map descriptionAdf) { + String issueTypeId = resolveIssueTypeId(ctx, projectKey, issueTypeName); Map fields = Map.of( "project", Map.of("key", projectKey), - "issuetype", Map.of("name", issueTypeName), + "issuetype", Map.of("id", issueTypeId), "summary", summary, "description", descriptionAdf); CreatedIssue created = exchange(() -> restClient.post() @@ -111,14 +169,56 @@ public CreatedIssue createIssue(JiraApiContext ctx, String projectKey, String is .accept(MediaType.APPLICATION_JSON) .body(Map.of("fields", fields)) .retrieve() - .onStatus(HttpStatusCode::isError, (req, res) -> mapError(res.getStatusCode(), true)) + .onStatus(HttpStatusCode::isError, (req, res) -> { throw mapError(res, true); }) .body(CreatedIssue.class), "createIssue"); if (created == null || created.key() == null) { - throw IntegrationsInfrastructureExceptions.jiraPushFailed("Jira returned no issue key"); + throw IntegrationsInfrastructureExceptions.jiraPushFailed( + "Jira accepted the request but returned no issue key (project '" + projectKey + + "', issue type id '" + issueTypeId + "')"); } return created; } + /** + * GET {@code /search/jql} → one page of issues matching {@code jql}, requesting only the + * {@code summary}, {@code description}, {@code issuetype} and {@code priority} fields. Token-paginated + * (the current Jira Cloud model: {@code nextPageToken} + {@code isLast}, no {@code total}). Returns the + * raw {@code issues} nodes plus the next-page token; the caller loops until {@code isLast}. + */ + public IssueSearchPage searchIssues(JiraApiContext ctx, String jql, int maxResults, String nextPageToken) { + StringBuilder uri = new StringBuilder(ctx.apiBase()) + .append("/search/jql?jql=").append(enc(jql)) + .append("&fields=").append(enc("summary,description,issuetype,priority")) + .append("&maxResults=").append(maxResults); + if (nextPageToken != null && !nextPageToken.isBlank()) { + uri.append("&nextPageToken=").append(enc(nextPageToken)); + } + IssueSearchResponse res = exchange(() -> restClient.get() + .uri(uri.toString()) + .header("Authorization", ctx.authHeader()) + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .onStatus(HttpStatusCode::isError, (req, r) -> { throw mapError(r, false); }) + .body(IssueSearchResponse.class), "searchIssues"); + if (res == null) { + return new IssueSearchPage(List.of(), true, null); + } + List issues = res.issues() == null ? List.of() : res.issues(); + return new IssueSearchPage(issues, res.isLast() == null || res.isLast(), res.nextPageToken()); + } + + /** Fetches every issue matching {@code jql} across all pages (created ASC ordering is the caller's). */ + public List searchAllIssues(JiraApiContext ctx, String jql) { + List all = new ArrayList<>(); + String token = null; + do { + IssueSearchPage page = searchIssues(ctx, jql, 100, token); + all.addAll(page.issues()); + token = page.isLast() ? null : page.nextPageToken(); + } while (token != null); + return all; + } + /** Browse URL for a created issue (uses the human site URL, not the OAuth API base). */ public String browseUrl(String browseBase, String issueKey) { return browseBase + "/browse/" + issueKey; @@ -126,19 +226,55 @@ public String browseUrl(String browseBase, String issueKey) { // Helpers + private static String enc(String raw) { + return URLEncoder.encode(raw, StandardCharsets.UTF_8); + } + /** - * Maps an error status inside the RestClient exchange. {@code onCreate} selects the 400 → push-failed - * mapping; otherwise 400 falls through to unreachable. Throwing here aborts the call with a mapped, - * token-free exception. + * Maps an error response inside the RestClient exchange, reading Jira's error body so the thrown + * exception is DIAGNOSABLE. {@code onCreate} selects the 400/404 → push-failed mapping. The body is + * parsed for Jira's {@code errorMessages} array and field {@code errors} map (token-free — bodies never + * carry credentials). Throwing here aborts the call with a mapped exception. */ - private static RuntimeException mapError(HttpStatusCode status, boolean onCreate) { - if (status.value() == 401 || status.value() == 403) { + private static RuntimeException mapError(org.springframework.http.client.ClientHttpResponse res, + boolean onCreate) throws IOException { + int status = res.getStatusCode().value(); + String detail = readJiraError(res); + if (status == 401 || status == 403) { return IntegrationsInfrastructureExceptions.jiraAuthFailed(); } - if (onCreate && status.value() == 400) { - return IntegrationsInfrastructureExceptions.jiraPushFailed("Jira rejected the request (400)"); + if (onCreate && (status == 400 || status == 404)) { + return IntegrationsInfrastructureExceptions.jiraPushFailed( + "Jira rejected the request (" + status + ")" + (detail.isBlank() ? "" : ": " + detail)); + } + return IntegrationsInfrastructureExceptions.jiraUnreachable( + "HTTP " + status + (detail.isBlank() ? "" : ": " + detail), null); + } + + /** + * Extracts Jira's {@code errorMessages} (array) and {@code errors} (field → message map) from an error + * response body into a compact, token-free string. Returns "" when the body is empty or unparseable. + */ + private static String readJiraError(org.springframework.http.client.ClientHttpResponse res) { + try { + byte[] raw = res.getBody().readAllBytes(); + if (raw.length == 0) { + return ""; + } + JsonNode body = ERROR_MAPPER.readTree(raw); + List parts = new ArrayList<>(); + JsonNode messages = body.get("errorMessages"); + if (messages != null && messages.isArray()) { + messages.forEach(m -> parts.add(m.asText())); + } + JsonNode errors = body.get("errors"); + if (errors != null && errors.isObject()) { + errors.fields().forEachRemaining(e -> parts.add(e.getKey() + ": " + e.getValue().asText())); + } + return String.join("; ", parts); + } catch (Exception e) { + return ""; } - return IntegrationsInfrastructureExceptions.jiraUnreachable("HTTP " + status.value(), null); } /** Runs a RestClient call, translating transport-level failures (connect/timeout) to JIRA_UNREACHABLE. */ @@ -153,9 +289,6 @@ private T exchange(java.util.function.Supplier call, String op) { } } - private static final org.springframework.core.ParameterizedTypeReference> ISSUE_TYPE_LIST = - new org.springframework.core.ParameterizedTypeReference<>() {}; - // Jackson-bound response records @JsonIgnoreProperties(ignoreUnknown = true) @@ -170,6 +303,32 @@ private record ProjectSearch(List values) {} @JsonIgnoreProperties(ignoreUnknown = true) public record JiraIssueType(String id, String name) {} + /** Response of {@code /issue/createmeta/{projectKey}/issuetypes}. */ + @JsonIgnoreProperties(ignoreUnknown = true) + private record CreateMetaIssueTypes(List issueTypes) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + public record CreatedIssue(String id, String key, String self) {} + + /** One issue returned by {@code /search/jql} with the subset of fields requested above. */ + @JsonIgnoreProperties(ignoreUnknown = true) + public record JiraIssue(String key, IssueFields fields) {} + + /** + * The requested field subset of a search hit. {@code description} is an ADF document (nested object) + * bound as a {@code Map} — {@link JiraAdfReader} flattens it to plain text for parsing. + */ + @JsonIgnoreProperties(ignoreUnknown = true) + public record IssueFields(String summary, Map description, + NamedRef issuetype, NamedRef priority) {} + + /** A Jira {name,id} reference (issue type, priority). */ @JsonIgnoreProperties(ignoreUnknown = true) - public record CreatedIssue(String key, String self) {} + public record NamedRef(String id, String name) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + private record IssueSearchResponse(List issues, Boolean isLast, String nextPageToken) {} + + /** One page of a token-paginated JQL search. */ + public record IssueSearchPage(List issues, boolean isLast, String nextPageToken) {} } diff --git a/src/test/java/com/kntro/reqsai/gateway/StubJiraOAuthConfig.java b/src/test/java/com/kntro/reqsai/gateway/StubJiraOAuthConfig.java index d894a903..5f83666e 100644 --- a/src/test/java/com/kntro/reqsai/gateway/StubJiraOAuthConfig.java +++ b/src/test/java/com/kntro/reqsai/gateway/StubJiraOAuthConfig.java @@ -51,7 +51,8 @@ public List listIssueTypes(JiraApiContext ctx, String projectKey) public CreatedIssue createIssue(JiraApiContext ctx, String projectKey, String issueTypeName, String summary, Map descriptionAdf) { apiBases.add(ctx.apiBase()); - return new CreatedIssue(projectKey + "-42", ctx.apiBase() + "/issue/" + projectKey + "-42"); + String key = projectKey + "-42"; + return new CreatedIssue("42", key, ctx.apiBase() + "/issue/" + key); } } diff --git a/src/test/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraClientTest.java b/src/test/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraClientTest.java new file mode 100644 index 00000000..5b9c090e --- /dev/null +++ b/src/test/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraClientTest.java @@ -0,0 +1,158 @@ +package com.kntro.reqsai.gateway.infrastructure.jira; + +import com.kntro.reqsai.gateway.infrastructure.jira.JiraClient.CreatedIssue; +import com.kntro.reqsai.gateway.infrastructure.jira.JiraClient.IssueSearchPage; +import com.kntro.reqsai.gateway.infrastructure.jira.JiraClient.JiraApiContext; +import com.kntro.reqsai.gateway.infrastructure.jira.JiraClient.JiraIssue; +import com.kntro.reqsai.gateway.infrastructure.jira.JiraClient.JiraIssueType; +import com.kntro.reqsai.shared.domain.exception.InfrastructureException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestClient; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.jsonPath; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.method; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +@Tag("unit") +@DisplayName("Infrastructure: JiraClient (dual-mode REST, project-scoped types, diagnosable errors, JQL search)") +class JiraClientTest { + + private static final JiraApiContext CTX = + JiraApiContext.apiToken("https://acme.atlassian.net", "pm@acme.com", "tok"); + private static final String BASE = "https://acme.atlassian.net/rest/api/3"; + + private RestClient.Builder builder; + private MockRestServiceServer server; + private JiraClient client; + + @BeforeEach + void setUp() { + builder = RestClient.builder(); + server = MockRestServiceServer.bindTo(builder).build(); + client = new JiraClient(builder); + } + + @Test + @DisplayName("listIssueTypes reads the PROJECT-SCOPED createmeta list and dedupes by id") + void project_scoped_issue_types_deduped() { + server.expect(requestTo(BASE + "/issue/createmeta/PAY/issuetypes?maxResults=200")) + .andExpect(method(org.springframework.http.HttpMethod.GET)) + .andRespond(withSuccess(""" + {"issueTypes":[ + {"id":"10001","name":"Historia"}, + {"id":"10001","name":"Historia"}, + {"id":"10002","name":"Tarea"} + ]}""", MediaType.APPLICATION_JSON)); + + List types = client.listIssueTypes(CTX, "PAY"); + + assertThat(types).extracting(JiraIssueType::id).containsExactly("10001", "10002"); + assertThat(types).extracting(JiraIssueType::name).containsExactly("Historia", "Tarea"); + server.verify(); + } + + @Test + @DisplayName("createIssue resolves the issue type NAME to its project id and sends issuetype:{id}") + void create_sends_issue_type_by_id() { + server.expect(requestTo(BASE + "/issue/createmeta/PAY/issuetypes?maxResults=200")) + .andRespond(withSuccess("{\"issueTypes\":[{\"id\":\"10001\",\"name\":\"Historia\"}]}", + MediaType.APPLICATION_JSON)); + server.expect(requestTo(BASE + "/issue")) + .andExpect(method(org.springframework.http.HttpMethod.POST)) + .andExpect(jsonPath("$.fields.issuetype.id").value("10001")) + .andExpect(jsonPath("$.fields.project.key").value("PAY")) + .andRespond(withStatus(HttpStatus.CREATED) + .body("{\"id\":\"42\",\"key\":\"PAY-42\",\"self\":\"https://acme.atlassian.net/rest/api/3/issue/42\"}") + .contentType(MediaType.APPLICATION_JSON)); + + CreatedIssue created = client.createIssue(CTX, "PAY", "Historia", "Login con Google", + Map.of("type", "doc", "version", 1, "content", List.of())); + + assertThat(created.key()).isEqualTo("PAY-42"); + assertThat(created.id()).isEqualTo("42"); + server.verify(); + } + + @Test + @DisplayName("createIssue surfaces Jira errorMessages + field errors on a 400 (diagnosable, token-free)") + void create_surfaces_jira_error_body() { + server.expect(requestTo(BASE + "/issue/createmeta/PAY/issuetypes?maxResults=200")) + .andRespond(withSuccess("{\"issueTypes\":[{\"id\":\"10001\",\"name\":\"Historia\"}]}", + MediaType.APPLICATION_JSON)); + server.expect(requestTo(BASE + "/issue")) + .andRespond(withStatus(HttpStatus.BAD_REQUEST) + .body("{\"errorMessages\":[\"Field 'customfield_10011' is required\"]," + + "\"errors\":{\"summary\":\"Summary must be provided.\"}}") + .contentType(MediaType.APPLICATION_JSON)); + + assertThatThrownBy(() -> client.createIssue(CTX, "PAY", "Historia", "x", + Map.of("type", "doc", "version", 1, "content", List.of()))) + .isInstanceOf(InfrastructureException.class) + .hasMessageContaining("Field 'customfield_10011' is required") + .hasMessageContaining("summary: Summary must be provided."); + server.verify(); + } + + @Test + @DisplayName("resolveIssueTypeId throws a diagnosable error listing available types when the name is invalid") + void resolve_unknown_issue_type_lists_available() { + server.expect(requestTo(BASE + "/issue/createmeta/PAY/issuetypes?maxResults=200")) + .andRespond(withSuccess("{\"issueTypes\":[{\"id\":\"10001\",\"name\":\"Historia\"}]}", + MediaType.APPLICATION_JSON)); + + assertThatThrownBy(() -> client.resolveIssueTypeId(CTX, "PAY", "Bug")) + .isInstanceOf(InfrastructureException.class) + .hasMessageContaining("issue type 'Bug' is not available") + .hasMessageContaining("Historia"); + server.verify(); + } + + @Test + @DisplayName("searchAllIssues follows nextPageToken until isLast and concatenates issues") + void search_paginates_by_token() { + server.expect(requestTo(org.hamcrest.Matchers.containsString("/search/jql?jql="))) + .andRespond(withSuccess(""" + {"issues":[{"key":"PAY-1","fields":{"summary":"One"}}], + "isLast":false,"nextPageToken":"tok-2"}""", MediaType.APPLICATION_JSON)); + server.expect(requestTo(org.hamcrest.Matchers.containsString("nextPageToken=tok-2"))) + .andRespond(withSuccess(""" + {"issues":[{"key":"PAY-2","fields":{"summary":"Two"}}], + "isLast":true}""", MediaType.APPLICATION_JSON)); + + List issues = client.searchAllIssues(CTX, + "project = \"PAY\" AND issuetype = \"Historia\" ORDER BY created ASC"); + + assertThat(issues).extracting(JiraIssue::key).containsExactly("PAY-1", "PAY-2"); + server.verify(); + } + + @Test + @DisplayName("a single search page reports isLast and exposes the raw issue fields node") + void single_search_page() { + server.expect(requestTo(org.hamcrest.Matchers.containsString("/search/jql?jql="))) + .andRespond(withSuccess(""" + {"issues":[{"key":"PAY-9","fields":{"summary":"Nine","priority":{"name":"High"}}}], + "isLast":true}""", MediaType.APPLICATION_JSON)); + + IssueSearchPage page = client.searchIssues(CTX, "project = \"PAY\"", 100, null); + + assertThat(page.isLast()).isTrue(); + assertThat(page.issues()).hasSize(1); + assertThat(page.issues().getFirst().fields().summary()).isEqualTo("Nine"); + assertThat(page.issues().getFirst().fields().priority().name()).isEqualTo("High"); + server.verify(); + } +} From ee07f0bd2c32c1bb3392db3b2e9e11fb456271a1 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Mon, 6 Jul 2026 21:51:59 -0500 Subject: [PATCH 42/72] feat(discovery): add discovery::api write port to import external issues as stories Adds DiscoveryStoryWritePort to the discovery::api named interface so the gateway can turn an external tracker issue into a user story without reaching into discovery internals. Discovery owns the transformation: - LLM path: the issue summary + plain-text description are fed to the existing RequirementGenerationPort, and the first generated story (role/action/benefit + acceptance criteria) is used. No regex parsing. - Deterministic fallback: when the model is unconfigured or generation fails/returns nothing, a safe mapping (title=summary, minimal valid role/action, description as benefit) still satisfies the UserStory invariants so import works without an LLM. Creation delegates to the existing CreateUserStoryCommandHandler, reusing the similarity/duplicate gate: a collision is reported as ImportedStory DUPLICATE (nothing created) with the existing story id, not raised as an error. checkDuplicate flags likely duplicates for the import preview without creating. The LLM never crosses the module boundary. --- .../api/DiscoveryStoryWritePort.java | 33 ++++ .../discovery/api/ExternalIssueInput.java | 26 +++ .../reqsai/discovery/api/ImportedStory.java | 39 ++++ .../discovery/api/StoryDuplicateCheck.java | 26 +++ .../reqsai/discovery/api/package-info.java | 11 +- .../service/DiscoveryStoryWritePortImpl.java | 184 ++++++++++++++++++ .../DiscoveryStoryWritePortImplTest.java | 164 ++++++++++++++++ 7 files changed, 480 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/kntro/reqsai/discovery/api/DiscoveryStoryWritePort.java create mode 100644 src/main/java/com/kntro/reqsai/discovery/api/ExternalIssueInput.java create mode 100644 src/main/java/com/kntro/reqsai/discovery/api/ImportedStory.java create mode 100644 src/main/java/com/kntro/reqsai/discovery/api/StoryDuplicateCheck.java create mode 100644 src/main/java/com/kntro/reqsai/discovery/application/service/DiscoveryStoryWritePortImpl.java create mode 100644 src/test/java/com/kntro/reqsai/discovery/application/service/DiscoveryStoryWritePortImplTest.java diff --git a/src/main/java/com/kntro/reqsai/discovery/api/DiscoveryStoryWritePort.java b/src/main/java/com/kntro/reqsai/discovery/api/DiscoveryStoryWritePort.java new file mode 100644 index 00000000..5e50eaa8 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/discovery/api/DiscoveryStoryWritePort.java @@ -0,0 +1,33 @@ +package com.kntro.reqsai.discovery.api; + +/** + * Public ACL interface of the Discovery bounded context for creating user stories from + * external tracker issues, accessible to other Spring Modulith modules (the {@code gateway} Jira import). + * The counterpart of {@link DiscoveryStoryReadPort}. + * + *

    Discovery owns the transformation: an external issue (summary + plain-text description) is turned into + * a well-formed story (role/action/benefit + acceptance criteria) by Discovery's existing LLM generation + * when configured, and by a deterministic safe mapping otherwise — so the LLM never crosses the module + * boundary. Creation reuses the existing {@code CreateUserStoryCommandHandler}, keeping the + * similarity/duplicate detection identical to manual and AI-generated stories: an import that collides with + * an existing story is reported as a {@link ImportedStory.Status#DUPLICATE} rather than created. + * + *

    Implementations are package-private Spring beans; callers depend only on this interface. All writes + * are tenant-scoped (schema resolved from the JWT {@code orgId}). + */ +public interface DiscoveryStoryWritePort { + + /** + * Transforms the external issue into a story and creates it, reusing the standard dedup gate. Returns + * {@link ImportedStory#created(java.util.UUID)} on success or {@link ImportedStory#duplicate} when the + * transformed story is a near-duplicate of an existing project story (nothing is created in that case). + */ + ImportedStory importFromExternalIssue(ExternalIssueInput input); + + /** + * Checks — without creating — whether the external issue would map to a near-duplicate of an existing + * project story, so the import preview can flag it. Returns {@link StoryDuplicateCheck#notDuplicate()} + * when the embedding model is unavailable (no similarity signal to report). + */ + StoryDuplicateCheck checkDuplicate(ExternalIssueInput input); +} diff --git a/src/main/java/com/kntro/reqsai/discovery/api/ExternalIssueInput.java b/src/main/java/com/kntro/reqsai/discovery/api/ExternalIssueInput.java new file mode 100644 index 00000000..339366ff --- /dev/null +++ b/src/main/java/com/kntro/reqsai/discovery/api/ExternalIssueInput.java @@ -0,0 +1,26 @@ +package com.kntro.reqsai.discovery.api; + +import org.jspecify.annotations.Nullable; + +import java.util.UUID; + +/** + * Raw material for importing an external tracker issue (e.g. a Jira issue) into the Discovery backlog as a + * user story, passed by another module (the {@code gateway}) to {@link DiscoveryStoryWritePort}. + * + *

    Deliberately provider-neutral and unstructured: it carries the issue's {@code summary} (title-ish + * line) and its {@code description} already flattened to plain text. Discovery owns the transformation + * into a well-formed story (role/action/benefit + acceptance criteria) — via its LLM generation when + * available, otherwise a deterministic safe mapping — so the AI stays inside the Discovery boundary. + * + * @param projectId project the story will belong to (tenant-scoped) + * @param summary the external issue summary (never blank; used as the story title / generation seed) + * @param description the external issue description flattened to plain text ({@code null}/blank allowed) + * @param language BCP-47 language tag to guide the LLM (e.g. {@code "es-PE"}); {@code null} = default + */ +public record ExternalIssueInput( + UUID projectId, + String summary, + @Nullable String description, + @Nullable String language +) {} diff --git a/src/main/java/com/kntro/reqsai/discovery/api/ImportedStory.java b/src/main/java/com/kntro/reqsai/discovery/api/ImportedStory.java new file mode 100644 index 00000000..711369fa --- /dev/null +++ b/src/main/java/com/kntro/reqsai/discovery/api/ImportedStory.java @@ -0,0 +1,39 @@ +package com.kntro.reqsai.discovery.api; + +import org.jspecify.annotations.Nullable; + +import java.util.UUID; + +/** + * Outcome of importing one external issue through {@link DiscoveryStoryWritePort}: either a new story was + * created, or the transformed story was detected as a near-duplicate of an existing one (reusing the same + * similarity/deduplication gate as manual and AI-generated creation) and therefore not + * created. + * + * @param status {@link Status#CREATED} or {@link Status#DUPLICATE} + * @param storyId the created story id when {@code CREATED}, else {@code null} + * @param existingStoryId the near-duplicate's story id when {@code DUPLICATE} and it could be resolved, + * else {@code null} + * @param similarity cosine similarity to the existing story when {@code DUPLICATE} (0 otherwise) + */ +public record ImportedStory( + Status status, + @Nullable UUID storyId, + @Nullable UUID existingStoryId, + double similarity +) { + + public enum Status { CREATED, DUPLICATE } + + public static ImportedStory created(UUID storyId) { + return new ImportedStory(Status.CREATED, storyId, null, 0.0); + } + + public static ImportedStory duplicate(@Nullable UUID existingStoryId, double similarity) { + return new ImportedStory(Status.DUPLICATE, null, existingStoryId, similarity); + } + + public boolean isDuplicate() { + return status == Status.DUPLICATE; + } +} diff --git a/src/main/java/com/kntro/reqsai/discovery/api/StoryDuplicateCheck.java b/src/main/java/com/kntro/reqsai/discovery/api/StoryDuplicateCheck.java new file mode 100644 index 00000000..755d869e --- /dev/null +++ b/src/main/java/com/kntro/reqsai/discovery/api/StoryDuplicateCheck.java @@ -0,0 +1,26 @@ +package com.kntro.reqsai.discovery.api; + +import org.jspecify.annotations.Nullable; + +import java.util.UUID; + +/** + * Result of checking whether a candidate story (built from an external issue) would be a near-duplicate of + * an existing project story without creating anything. Used by the import preview so the + * caller can flag likely duplicates before the user commits to importing. + * + * @param duplicate true when the candidate's similarity to an existing story is at/above the same + * deduplication threshold that creation enforces + * @param existingStoryId the most-similar existing story id when one was found, else {@code null} + * @param similarity cosine similarity to that story (0 when none / embedding unavailable) + */ +public record StoryDuplicateCheck( + boolean duplicate, + @Nullable UUID existingStoryId, + double similarity +) { + + public static StoryDuplicateCheck notDuplicate() { + return new StoryDuplicateCheck(false, null, 0.0); + } +} diff --git a/src/main/java/com/kntro/reqsai/discovery/api/package-info.java b/src/main/java/com/kntro/reqsai/discovery/api/package-info.java index 1e8c8440..f890bec8 100644 --- a/src/main/java/com/kntro/reqsai/discovery/api/package-info.java +++ b/src/main/java/com/kntro/reqsai/discovery/api/package-info.java @@ -3,9 +3,14 @@ *

    * Exposes {@link com.kntro.reqsai.discovery.api.DiscoveryStoryReadPort} and its read-only value * records ({@link com.kntro.reqsai.discovery.api.StoryView}, - * {@link com.kntro.reqsai.discovery.api.AcceptanceCriterionView}) so other modules (e.g. - * {@code integrations}) can read user stories to push them to external trackers without reaching into - * Discovery internals. No JPA entities cross this boundary. + * {@link com.kntro.reqsai.discovery.api.AcceptanceCriterionView}) so other modules (e.g. the + * {@code gateway}) can read user stories to push them to external trackers, and + * {@link com.kntro.reqsai.discovery.api.DiscoveryStoryWritePort} (with + * {@link com.kntro.reqsai.discovery.api.ExternalIssueInput}, + * {@link com.kntro.reqsai.discovery.api.ImportedStory} and + * {@link com.kntro.reqsai.discovery.api.StoryDuplicateCheck}) so the {@code gateway} can import external + * issues as stories — Discovery owns the LLM transformation and reuses its create/dedup use case behind + * the port. No JPA entities cross this boundary. *

    * Declare {@code allowedDependencies = "discovery::api"} in the consuming module's * {@code @ApplicationModule} annotation to make Spring Modulith enforce the boundary. diff --git a/src/main/java/com/kntro/reqsai/discovery/application/service/DiscoveryStoryWritePortImpl.java b/src/main/java/com/kntro/reqsai/discovery/application/service/DiscoveryStoryWritePortImpl.java new file mode 100644 index 00000000..e9134e39 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/discovery/application/service/DiscoveryStoryWritePortImpl.java @@ -0,0 +1,184 @@ +package com.kntro.reqsai.discovery.application.service; + +import com.kntro.reqsai.discovery.api.DiscoveryStoryWritePort; +import com.kntro.reqsai.discovery.api.ExternalIssueInput; +import com.kntro.reqsai.discovery.api.ImportedStory; +import com.kntro.reqsai.discovery.api.StoryDuplicateCheck; +import com.kntro.reqsai.discovery.application.command.CreateUserStoryCommand; +import com.kntro.reqsai.discovery.application.handler.CreateUserStoryCommandHandler; +import com.kntro.reqsai.discovery.application.port.GenerationResult; +import com.kntro.reqsai.discovery.application.port.GenerationResult.GeneratedStory; +import com.kntro.reqsai.discovery.application.port.RequirementGenerationPort; +import com.kntro.reqsai.discovery.application.port.UserStoryRepository; +import com.kntro.reqsai.discovery.domain.exception.DiscoveryError; +import com.kntro.reqsai.discovery.domain.model.Priority; +import com.kntro.reqsai.discovery.domain.model.UserStory; +import com.kntro.reqsai.shared.application.port.EmbeddingPort; +import com.kntro.reqsai.shared.domain.exception.DomainException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +/** + * Package-private cross-context implementation of {@link DiscoveryStoryWritePort}. Transforms an external + * tracker issue into a well-formed {@link UserStory} and creates it through the existing + * {@link CreateUserStoryCommandHandler}, so the similarity/deduplication gate is identical to manual and + * AI-generated creation. + * + *

    Transformation (two paths, documented): + *

      + *
    1. LLM path — when {@link RequirementGenerationPort#isAvailable()} the issue's summary + + * plain-text description are fed to Discovery's existing generation as a short transcript; the first + * generated story (already role/action/benefit + acceptance criteria) is used. This is the requested + * behaviour: no regex parsing of Jira text.
    2. + *
    3. Deterministic fallback — when the model is unconfigured or generation fails/returns + * nothing, a safe mapping is used: {@code title = summary}, a minimal valid role/action, and the + * description (or a default) as the benefit. This guarantees the required story fields are always + * satisfied so import works without an LLM.
    4. + *
    + * Either way the resulting story goes through the standard create handler; a near-duplicate is reported as + * {@link ImportedStory.Status#DUPLICATE} (nothing created), not propagated as an error. + */ +@Component +@RequiredArgsConstructor +@Slf4j +class DiscoveryStoryWritePortImpl implements DiscoveryStoryWritePort { + + private static final int TITLE_MAX = 200; + private static final int FIELD_MAX = 500; + + private final RequirementGenerationPort generationPort; + private final CreateUserStoryCommandHandler createUserStory; + private final UserStoryRepository stories; + private final EmbeddingPort embeddingPort; + + @Override + @Transactional + public ImportedStory importFromExternalIssue(ExternalIssueInput input) { + GeneratedStory gen = transform(input); + try { + CreateUserStoryCommand command = new CreateUserStoryCommand( + input.projectId(), gen.title(), gen.role(), gen.action(), gen.benefit(), + gen.priority(), gen.storyPoints()); + UserStory saved = createUserStory.handle(command); + if (gen.acceptanceCriteria() != null && !gen.acceptanceCriteria().isEmpty()) { + gen.acceptanceCriteria().forEach(c -> + saved.addAcceptanceCriterion(c.scenario(), c.given(), c.when(), c.then())); + stories.save(saved); + } + return ImportedStory.created(saved.getId()); + } catch (DomainException e) { + if (e.error() == DiscoveryError.DUPLICATE_USER_STORY) { + StoryDuplicateCheck match = resolveDuplicate(input.projectId(), gen); + return ImportedStory.duplicate(match.existingStoryId(), match.similarity()); + } + throw e; + } + } + + @Override + @Transactional(readOnly = true) + public StoryDuplicateCheck checkDuplicate(ExternalIssueInput input) { + if (!embeddingPort.isAvailable()) { + return StoryDuplicateCheck.notDuplicate(); + } + return resolveDuplicate(input.projectId(), transform(input)); + } + + /** + * Finds the most similar existing story to the candidate and flags it as a duplicate when the score is + * at/above the shared threshold. Uses the same canonical text + embedding as the create-time gate. + */ + private StoryDuplicateCheck resolveDuplicate(java.util.UUID projectId, GeneratedStory gen) { + if (!embeddingPort.isAvailable()) { + return StoryDuplicateCheck.notDuplicate(); + } + UserStory candidate = new UserStory(projectId, gen.title(), gen.role(), gen.action(), + gen.benefit(), gen.priority(), gen.storyPoints()); + Optional match = + stories.findMostSimilar(projectId, embeddingPort.embed(candidate.toCanonicalText())); + return match + .map(s -> new StoryDuplicateCheck(s.similarity() >= UserStory.DUPLICATE_THRESHOLD, + s.storyId(), s.similarity())) + .orElseGet(StoryDuplicateCheck::notDuplicate); + } + + /** LLM transformation with a deterministic fallback that always yields a valid story. */ + private GeneratedStory transform(ExternalIssueInput input) { + if (generationPort.isAvailable()) { + try { + GenerationResult result = generationPort.generate(seedTranscript(input), language(input)); + if (result != null && result.stories() != null && !result.stories().isEmpty()) { + return sanitize(result.stories().getFirst(), input); + } + log.info("Generation returned no story for imported issue '{}'; using safe fallback mapping", + input.summary()); + } catch (RuntimeException e) { + log.warn("Generation failed for imported issue '{}'; using safe fallback mapping: {}", + input.summary(), e.getMessage()); + } + } + return fallback(input); + } + + /** Feeds the LLM the issue as a tiny transcript so it produces one structured story. */ + private static String seedTranscript(ExternalIssueInput input) { + String description = input.description() == null ? "" : input.description(); + return ("Convert the following tracker issue into a single user story.\n" + + "Title: " + input.summary() + "\n" + + "Description: " + description).strip(); + } + + private static String language(ExternalIssueInput input) { + return input.language() == null || input.language().isBlank() ? "en-US" : input.language(); + } + + /** + * Ensures an LLM-generated story satisfies the {@link UserStory} invariants (non-blank, bounded fields) + * regardless of what the model returned, so a sparse generation never fails the create. + */ + private static GeneratedStory sanitize(GeneratedStory gen, ExternalIssueInput input) { + String title = clamp(nonBlank(gen.title(), input.summary()), TITLE_MAX); + String role = clamp(nonBlank(gen.role(), "stakeholder"), FIELD_MAX); + String action = clamp(nonBlank(gen.action(), deriveAction(input.summary())), FIELD_MAX); + String benefit = clamp(nonBlank(gen.benefit(), deriveBenefit(input)), FIELD_MAX); + Priority priority = gen.priority() == null ? Priority.MEDIUM : gen.priority(); + List criteria = + gen.acceptanceCriteria() == null ? List.of() : gen.acceptanceCriteria(); + return new GeneratedStory(title, role, action, benefit, priority, gen.storyPoints(), criteria); + } + + /** Deterministic safe mapping: title = summary, minimal valid role/action, description as benefit. */ + private static GeneratedStory fallback(ExternalIssueInput input) { + String title = clamp(nonBlank(input.summary(), "Imported issue"), TITLE_MAX); + return new GeneratedStory( + title, + "stakeholder", + clamp(deriveAction(title), FIELD_MAX), + clamp(deriveBenefit(input), FIELD_MAX), + Priority.MEDIUM, + null, + List.of()); + } + + private static String deriveAction(String summary) { + return "achieve: " + summary; + } + + private static String deriveBenefit(ExternalIssueInput input) { + String description = input.description() == null ? "" : input.description().strip(); + return description.isBlank() ? "the imported requirement is captured in the backlog" : description; + } + + private static String nonBlank(String value, String fallback) { + return value == null || value.isBlank() ? fallback : value.strip(); + } + + private static String clamp(String value, int max) { + return value.length() <= max ? value : value.substring(0, max); + } +} diff --git a/src/test/java/com/kntro/reqsai/discovery/application/service/DiscoveryStoryWritePortImplTest.java b/src/test/java/com/kntro/reqsai/discovery/application/service/DiscoveryStoryWritePortImplTest.java new file mode 100644 index 00000000..59d0d88e --- /dev/null +++ b/src/test/java/com/kntro/reqsai/discovery/application/service/DiscoveryStoryWritePortImplTest.java @@ -0,0 +1,164 @@ +package com.kntro.reqsai.discovery.application.service; + +import com.kntro.reqsai.discovery.api.ExternalIssueInput; +import com.kntro.reqsai.discovery.api.ImportedStory; +import com.kntro.reqsai.discovery.api.StoryDuplicateCheck; +import com.kntro.reqsai.discovery.application.handler.CreateUserStoryCommandHandler; +import com.kntro.reqsai.discovery.application.port.GenerationResult; +import com.kntro.reqsai.discovery.application.port.GenerationResult.GeneratedCriterion; +import com.kntro.reqsai.discovery.application.port.GenerationResult.GeneratedStory; +import com.kntro.reqsai.discovery.application.port.RequirementGenerationPort; +import com.kntro.reqsai.discovery.application.port.UserStoryRepository; +import com.kntro.reqsai.discovery.domain.model.Priority; +import com.kntro.reqsai.discovery.domain.model.UserStory; +import com.kntro.reqsai.shared.application.port.EmbeddingPort; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@Tag("unit") +@ExtendWith(MockitoExtension.class) +@DisplayName("Application: Discovery story write port (external issue import)") +class DiscoveryStoryWritePortImplTest { + + private static final UUID PROJECT = UUID.randomUUID(); + + @Mock + private RequirementGenerationPort generationPort; + @Mock + private UserStoryRepository stories; + @Mock + private EmbeddingPort embeddingPort; + + private DiscoveryStoryWritePortImpl port; + + @BeforeEach + void setUp() { + UserStoryDeduplicationService dedup = new UserStoryDeduplicationService(stories, embeddingPort); + CreateUserStoryCommandHandler createHandler = new CreateUserStoryCommandHandler(stories, dedup); + port = new DiscoveryStoryWritePortImpl(generationPort, createHandler, stories, embeddingPort); + } + + @Test + @DisplayName("LLM path: uses the generated role/action/benefit + criteria and creates the story") + void llm_path_creates_structured_story() { + when(generationPort.isAvailable()).thenReturn(true); + when(generationPort.generate(any(), any())).thenReturn(new GenerationResult(List.of( + new GeneratedStory("Login con Google", "usuario registrado", + "iniciar sesión con Google", "no recordar otra contraseña", + Priority.HIGH, 3, + List.of(new GeneratedCriterion("ok", "en login", "click Google", "redirige a OAuth")))))); + when(stories.save(any())).thenAnswer(i -> i.getArgument(0)); + // embeddingPort.isAvailable() defaults to false -> dedup skipped + + ImportedStory result = port.importFromExternalIssue(new ExternalIssueInput( + PROJECT, "Login con Google", "Como usuario quiero entrar con Google", "es-PE")); + + assertThat(result.status()).isEqualTo(ImportedStory.Status.CREATED); + ArgumentCaptor saved = ArgumentCaptor.forClass(UserStory.class); + verify(stories, org.mockito.Mockito.atLeastOnce()).save(saved.capture()); + UserStory story = saved.getValue(); + assertThat(story.getTitle()).isEqualTo("Login con Google"); + assertThat(story.getRole()).isEqualTo("usuario registrado"); + assertThat(story.getAction()).isEqualTo("iniciar sesión con Google"); + assertThat(story.getAcceptanceCriteria()).hasSize(1); + } + + @Test + @DisplayName("fallback path: no LLM configured -> safe deterministic mapping still satisfies validation") + void fallback_path_when_llm_unavailable() { + when(generationPort.isAvailable()).thenReturn(false); + when(stories.save(any())).thenAnswer(i -> i.getArgument(0)); + + ImportedStory result = port.importFromExternalIssue(new ExternalIssueInput( + PROJECT, "Bulk CSV import", "Upload a CSV to seed the backlog", null)); + + assertThat(result.status()).isEqualTo(ImportedStory.Status.CREATED); + ArgumentCaptor saved = ArgumentCaptor.forClass(UserStory.class); + verify(stories).save(saved.capture()); + UserStory story = saved.getValue(); + assertThat(story.getTitle()).isEqualTo("Bulk CSV import"); + assertThat(story.getRole()).isNotBlank(); + assertThat(story.getAction()).isNotBlank(); + assertThat(story.getBenefit()).contains("Upload a CSV"); + } + + @Test + @DisplayName("fallback path: generation failure falls back rather than aborting the import") + void fallback_when_generation_throws() { + when(generationPort.isAvailable()).thenReturn(true); + when(generationPort.generate(any(), any())).thenThrow(new RuntimeException("model timeout")); + when(stories.save(any())).thenAnswer(i -> i.getArgument(0)); + + ImportedStory result = port.importFromExternalIssue(new ExternalIssueInput( + PROJECT, "Password reset", "As a user I want to reset my password", "en-US")); + + assertThat(result.status()).isEqualTo(ImportedStory.Status.CREATED); + verify(stories).save(any()); + } + + @Test + @DisplayName("duplicate: create hits the dedup gate -> reported as DUPLICATE with the existing story id") + void duplicate_is_reported_not_created() { + UUID existing = UUID.randomUUID(); + when(generationPort.isAvailable()).thenReturn(false); + when(embeddingPort.isAvailable()).thenReturn(true); + when(embeddingPort.embed(any())).thenReturn(new float[EmbeddingPort.DIMENSIONS]); + when(stories.highestSimilarity(any(), any())).thenReturn(Optional.of(0.93)); + when(stories.findMostSimilar(any(), any())) + .thenReturn(Optional.of(new UserStoryRepository.SimilarStory(existing, 0.93))); + + ImportedStory result = port.importFromExternalIssue(new ExternalIssueInput( + PROJECT, "Duplicate story", "same as an existing one", null)); + + assertThat(result.status()).isEqualTo(ImportedStory.Status.DUPLICATE); + assertThat(result.existingStoryId()).isEqualTo(existing); + assertThat(result.similarity()).isEqualTo(0.93); + verify(stories, never()).save(any()); + } + + @Test + @DisplayName("checkDuplicate: flags a near-duplicate without creating anything") + void check_duplicate_flags_without_creating() { + UUID existing = UUID.randomUUID(); + when(generationPort.isAvailable()).thenReturn(false); + when(embeddingPort.isAvailable()).thenReturn(true); + when(embeddingPort.embed(any())).thenReturn(new float[EmbeddingPort.DIMENSIONS]); + when(stories.findMostSimilar(any(), any())) + .thenReturn(Optional.of(new UserStoryRepository.SimilarStory(existing, 0.88))); + + StoryDuplicateCheck check = port.checkDuplicate(new ExternalIssueInput( + PROJECT, "Maybe duplicate", "similar", null)); + + assertThat(check.duplicate()).isTrue(); + assertThat(check.existingStoryId()).isEqualTo(existing); + verify(stories, never()).save(any()); + } + + @Test + @DisplayName("checkDuplicate: returns not-duplicate when the embedding model is unavailable") + void check_duplicate_no_embedding() { + when(embeddingPort.isAvailable()).thenReturn(false); + + StoryDuplicateCheck check = port.checkDuplicate(new ExternalIssueInput( + PROJECT, "Anything", "x", null)); + + assertThat(check.duplicate()).isFalse(); + assertThat(check.existingStoryId()).isNull(); + } +} From 1b3eada6aa1ab9266317a27321abe590a254614d Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Tue, 7 Jul 2026 11:51:35 -0500 Subject: [PATCH 43/72] fix(discovery): isolate external-issue import per transaction with pre-check dedup importFromExternalIssue now runs in its own REQUIRES_NEW transaction and detects a near-duplicate BEFORE invoking CreateUserStoryCommandHandler, instead of catching the handler's DUPLICATE_USER_STORY exception. Catching that throw still left the surrounding transaction rollback-only, which would poison a batch import; the pre-check uses the same canonical text + embedding + threshold as the create-time gate (which stays as a backstop), so behaviour is identical while a per-issue rollback no longer aborts the caller's batch. --- .../service/DiscoveryStoryWritePortImpl.java | 47 ++++++++++++------- 1 file changed, 29 insertions(+), 18 deletions(-) diff --git a/src/main/java/com/kntro/reqsai/discovery/application/service/DiscoveryStoryWritePortImpl.java b/src/main/java/com/kntro/reqsai/discovery/application/service/DiscoveryStoryWritePortImpl.java index e9134e39..5ecb11e8 100644 --- a/src/main/java/com/kntro/reqsai/discovery/application/service/DiscoveryStoryWritePortImpl.java +++ b/src/main/java/com/kntro/reqsai/discovery/application/service/DiscoveryStoryWritePortImpl.java @@ -18,6 +18,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import java.util.List; @@ -56,28 +57,38 @@ class DiscoveryStoryWritePortImpl implements DiscoveryStoryWritePort { private final UserStoryRepository stories; private final EmbeddingPort embeddingPort; + /** + * Imports one issue in its OWN transaction ({@link Propagation#REQUIRES_NEW}) so a per-issue rollback + * (e.g. a rare check→create duplicate race) never poisons the caller's batch — matching how + * {@code StoryExtractionService} isolates each AI-generated story. Must be invoked from another bean + * (the {@code gateway} handler) for the proxy to apply the new transaction. + * + *

    A near-duplicate is detected BEFORE invoking the {@link CreateUserStoryCommandHandler}: were the + * handler to throw the duplicate exception, that throw would cross its {@code @Transactional} boundary + * and mark this transaction rollback-only even though we catch it. The pre-check uses the same canonical + * text + embedding + threshold as the create-time gate, so behaviour is identical; the handler still + * guards as a backstop. + */ @Override - @Transactional + @Transactional(propagation = Propagation.REQUIRES_NEW) public ImportedStory importFromExternalIssue(ExternalIssueInput input) { GeneratedStory gen = transform(input); - try { - CreateUserStoryCommand command = new CreateUserStoryCommand( - input.projectId(), gen.title(), gen.role(), gen.action(), gen.benefit(), - gen.priority(), gen.storyPoints()); - UserStory saved = createUserStory.handle(command); - if (gen.acceptanceCriteria() != null && !gen.acceptanceCriteria().isEmpty()) { - gen.acceptanceCriteria().forEach(c -> - saved.addAcceptanceCriterion(c.scenario(), c.given(), c.when(), c.then())); - stories.save(saved); - } - return ImportedStory.created(saved.getId()); - } catch (DomainException e) { - if (e.error() == DiscoveryError.DUPLICATE_USER_STORY) { - StoryDuplicateCheck match = resolveDuplicate(input.projectId(), gen); - return ImportedStory.duplicate(match.existingStoryId(), match.similarity()); - } - throw e; + + StoryDuplicateCheck dup = resolveDuplicate(input.projectId(), gen); + if (dup.duplicate()) { + return ImportedStory.duplicate(dup.existingStoryId(), dup.similarity()); + } + + CreateUserStoryCommand command = new CreateUserStoryCommand( + input.projectId(), gen.title(), gen.role(), gen.action(), gen.benefit(), + gen.priority(), gen.storyPoints()); + UserStory saved = createUserStory.handle(command); + if (gen.acceptanceCriteria() != null && !gen.acceptanceCriteria().isEmpty()) { + gen.acceptanceCriteria().forEach(c -> + saved.addAcceptanceCriterion(c.scenario(), c.given(), c.when(), c.then())); + stories.save(saved); } + return ImportedStory.created(saved.getId()); } @Override From 4831173dcd9341e0837a714852800dcb845cc5d1 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Tue, 7 Jul 2026 11:51:43 -0500 Subject: [PATCH 44/72] feat(gateway): add import contracts to the integration provider port Adds the reverse of pushStory: IntegrationProvider.searchImportableIssues returns provider-neutral RemoteIssue rows (summary + plain-text description + mapped priority), plus the application command/query (ImportJiraStories, PreviewJiraImport) and the result records (ImportStoryResult with imported/duplicate/failed, ImportPreview candidates, BatchImportResult counts) that model the locked import contract. --- .../command/ImportJiraStoriesCommand.java | 15 ++++++ .../application/port/IntegrationProvider.java | 15 ++++++ .../query/PreviewJiraImportQuery.java | 12 +++++ .../application/result/BatchImportResult.java | 18 +++++++ .../application/result/ImportPreview.java | 29 +++++++++++ .../application/result/ImportStoryResult.java | 52 +++++++++++++++++++ 6 files changed, 141 insertions(+) create mode 100644 src/main/java/com/kntro/reqsai/gateway/application/command/ImportJiraStoriesCommand.java create mode 100644 src/main/java/com/kntro/reqsai/gateway/application/query/PreviewJiraImportQuery.java create mode 100644 src/main/java/com/kntro/reqsai/gateway/application/result/BatchImportResult.java create mode 100644 src/main/java/com/kntro/reqsai/gateway/application/result/ImportPreview.java create mode 100644 src/main/java/com/kntro/reqsai/gateway/application/result/ImportStoryResult.java diff --git a/src/main/java/com/kntro/reqsai/gateway/application/command/ImportJiraStoriesCommand.java b/src/main/java/com/kntro/reqsai/gateway/application/command/ImportJiraStoriesCommand.java new file mode 100644 index 00000000..c6ca81a8 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/command/ImportJiraStoriesCommand.java @@ -0,0 +1,15 @@ +package com.kntro.reqsai.gateway.application.command; + +import org.jspecify.annotations.Nullable; + +import java.util.List; +import java.util.UUID; + +/** + * Intent to import Jira issues from the project's configured target into the backlog as user stories. + * + * @param projectId the project (its {@code project_integration_targets} row says WHERE to pull from) + * @param issueKeys the specific Jira issue keys to import; {@code null}/empty means all eligible issues + * @param requestedBy caller id (authorization already enforced at the controller) + */ +public record ImportJiraStoriesCommand(UUID projectId, @Nullable List issueKeys, UUID requestedBy) {} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/port/IntegrationProvider.java b/src/main/java/com/kntro/reqsai/gateway/application/port/IntegrationProvider.java index ceafdf77..ab2c9b0d 100644 --- a/src/main/java/com/kntro/reqsai/gateway/application/port/IntegrationProvider.java +++ b/src/main/java/com/kntro/reqsai/gateway/application/port/IntegrationProvider.java @@ -33,6 +33,13 @@ public interface IntegrationProvider { /** Creates a tracker issue from a Reqs-AI story and returns its key + browse URL. */ PushedIssue pushStory(ProviderCredentials credentials, String projectKey, String issueTypeName, StoryView story); + /** + * Fetches the tracker issues eligible for import from {@code projectKey} of type {@code issueTypeName} + * (all pages), each flattened to a provider-neutral {@link RemoteIssue} (summary + plain-text + * description + mapped priority). The reverse of {@link #pushStory}. + */ + List searchImportableIssues(ProviderCredentials credentials, String projectKey, String issueTypeName); + /** * Decrypted credentials for a single provider call (never persisted, never logged). Carries both * credential shapes; {@link #credentialType} selects which is populated: @@ -65,4 +72,12 @@ record RemoteIssueType(String id, String name) {} /** The result of a successful push ({issueKey, issueUrl}). */ record PushedIssue(String issueKey, String issueUrl) {} + + /** + * A tracker issue eligible for import, flattened to provider-neutral fields. {@code priority} is a + * Reqs-AI {@code Priority} name (the provider maps the tracker's priority scale); {@code description} + * is plain text (ADF flattened for Jira). {@code issueType} is the tracker's type label. + */ + record RemoteIssue(String issueKey, String summary, @Nullable String issueType, + @Nullable String description, String priority) {} } diff --git a/src/main/java/com/kntro/reqsai/gateway/application/query/PreviewJiraImportQuery.java b/src/main/java/com/kntro/reqsai/gateway/application/query/PreviewJiraImportQuery.java new file mode 100644 index 00000000..57d50f94 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/query/PreviewJiraImportQuery.java @@ -0,0 +1,12 @@ +package com.kntro.reqsai.gateway.application.query; + +import java.util.UUID; + +/** + * Query for the Jira import preview: lists the candidate issues of the project's configured target and + * flags likely duplicates, without creating anything. + * + * @param projectId the project whose target defines WHERE to pull from + * @param requestedBy caller id (authorization already enforced at the controller) + */ +public record PreviewJiraImportQuery(UUID projectId, UUID requestedBy) {} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/result/BatchImportResult.java b/src/main/java/com/kntro/reqsai/gateway/application/result/BatchImportResult.java new file mode 100644 index 00000000..cfc00325 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/result/BatchImportResult.java @@ -0,0 +1,18 @@ +package com.kntro.reqsai.gateway.application.result; + +import java.util.List; + +/** + * Aggregate result of a Jira import: per-issue {@link ImportStoryResult}s plus the counts required by the + * locked contract. {@code imported} counts created stories, {@code skipped} counts duplicates, and + * {@code failed} counts per-issue failures (which never abort the batch). + */ +public record BatchImportResult(int imported, int skipped, int failed, List results) { + + public static BatchImportResult of(List results) { + int imported = (int) results.stream().filter(r -> r.status() == ImportStoryResult.Status.IMPORTED).count(); + int skipped = (int) results.stream().filter(r -> r.status() == ImportStoryResult.Status.DUPLICATE).count(); + int failed = (int) results.stream().filter(r -> r.status() == ImportStoryResult.Status.FAILED).count(); + return new BatchImportResult(imported, skipped, failed, results); + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/result/ImportPreview.java b/src/main/java/com/kntro/reqsai/gateway/application/result/ImportPreview.java new file mode 100644 index 00000000..b5f4d005 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/result/ImportPreview.java @@ -0,0 +1,29 @@ +package com.kntro.reqsai.gateway.application.result; + +import org.jspecify.annotations.Nullable; + +import java.util.List; +import java.util.UUID; + +/** + * Preview of a Jira import: the candidate issues eligible for import, each flagged as a likely duplicate + * (detected via the discovery similarity path WITHOUT creating anything). + */ +public record ImportPreview(int total, List issues) { + + public static ImportPreview of(List issues) { + return new ImportPreview(issues.size(), issues); + } + + /** + * One candidate issue. {@code duplicate} is true when its mapped story would collide with an existing + * story; {@code existingStoryId} carries that story's id when resolved. + */ + public record Candidate( + String jiraIssueKey, + String summary, + @Nullable String issueType, + boolean duplicate, + @Nullable UUID existingStoryId + ) {} +} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/result/ImportStoryResult.java b/src/main/java/com/kntro/reqsai/gateway/application/result/ImportStoryResult.java new file mode 100644 index 00000000..a6178d12 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/result/ImportStoryResult.java @@ -0,0 +1,52 @@ +package com.kntro.reqsai.gateway.application.result; + +import org.jspecify.annotations.Nullable; + +import java.util.UUID; + +/** + * Result of importing one Jira issue. {@code status} is one of {@code imported} / {@code duplicate} / + * {@code failed}: + *

      + *
    • {@code imported} — a story was created; {@code storyId} is set.
    • + *
    • {@code duplicate} — the issue mapped to a near-duplicate of an existing story and was skipped + * (nothing created); {@code storyId} is null.
    • + *
    • {@code failed} — the issue could not be imported; {@code message} carries a token-free reason.
    • + *
    + */ +public record ImportStoryResult( + String jiraIssueKey, + @Nullable UUID storyId, + Status status, + @Nullable String message +) { + + public enum Status { + IMPORTED("imported"), + DUPLICATE("duplicate"), + FAILED("failed"); + + private final String wire; + + Status(String wire) { + this.wire = wire; + } + + /** Lowercase wire value used in the locked API contract. */ + public String wire() { + return wire; + } + } + + public static ImportStoryResult imported(String jiraIssueKey, UUID storyId) { + return new ImportStoryResult(jiraIssueKey, storyId, Status.IMPORTED, null); + } + + public static ImportStoryResult duplicate(String jiraIssueKey) { + return new ImportStoryResult(jiraIssueKey, null, Status.DUPLICATE, null); + } + + public static ImportStoryResult failed(String jiraIssueKey, String message) { + return new ImportStoryResult(jiraIssueKey, null, Status.FAILED, message); + } +} From 8fdc634702f9230c7c9beb90adcc2dcae2dfcb7d Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Tue, 7 Jul 2026 11:51:51 -0500 Subject: [PATCH 45/72] feat(gateway): fetch and map importable Jira issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements searchImportableIssues in JiraProvider: JQL-searches all issues of the target project + issue type (created ASC, all pages), flattens each to a RemoteIssue (ADF description → plain text, Jira priority → Reqs-AI Priority: Highest/High→HIGH, Low/Lowest→LOW, else MEDIUM). Adds the JIRA_IMPORT_FAILED infrastructure error + factory for surfacing import-side Jira failures. --- .../IntegrationsInfrastructureError.java | 1 + .../IntegrationsInfrastructureExceptions.java | 5 +++ .../infrastructure/jira/JiraProvider.java | 34 +++++++++++++++++++ 3 files changed, 40 insertions(+) diff --git a/src/main/java/com/kntro/reqsai/gateway/infrastructure/exception/IntegrationsInfrastructureError.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/exception/IntegrationsInfrastructureError.java index 6e6bde22..25e1506f 100644 --- a/src/main/java/com/kntro/reqsai/gateway/infrastructure/exception/IntegrationsInfrastructureError.java +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/exception/IntegrationsInfrastructureError.java @@ -13,6 +13,7 @@ public enum IntegrationsInfrastructureError implements ErrorCatalog { JIRA_AUTH_FAILED(HttpStatus.UNAUTHORIZED), JIRA_UNREACHABLE(HttpStatus.BAD_GATEWAY), JIRA_PUSH_FAILED(HttpStatus.BAD_GATEWAY), + JIRA_IMPORT_FAILED(HttpStatus.BAD_GATEWAY), INTEGRATION_ENCRYPTION_ERROR(HttpStatus.INTERNAL_SERVER_ERROR), /** The Jira OAuth authorization-code / refresh-token exchange with Atlassian failed. */ diff --git a/src/main/java/com/kntro/reqsai/gateway/infrastructure/exception/IntegrationsInfrastructureExceptions.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/exception/IntegrationsInfrastructureExceptions.java index 63bd6ecb..257ac354 100644 --- a/src/main/java/com/kntro/reqsai/gateway/infrastructure/exception/IntegrationsInfrastructureExceptions.java +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/exception/IntegrationsInfrastructureExceptions.java @@ -29,6 +29,11 @@ public static InfrastructureException jiraPushFailed(String reason) { "Jira rejected the issue creation: " + reason, null); } + public static InfrastructureException jiraImportFailed(String reason, Throwable cause) { + return new InfrastructureException(IntegrationsInfrastructureError.JIRA_IMPORT_FAILED, + "Jira import failed: " + reason, cause); + } + public static InfrastructureException encryptionError(String reason, Throwable cause) { return new InfrastructureException(IntegrationsInfrastructureError.INTEGRATION_ENCRYPTION_ERROR, "Integration secret encryption failed: " + reason, cause); diff --git a/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraProvider.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraProvider.java index d88f8d7e..da159865 100644 --- a/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraProvider.java +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraProvider.java @@ -58,6 +58,40 @@ public PushedIssue pushStory(ProviderCredentials c, String projectKey, String is return new PushedIssue(created.key(), jira.browseUrl(ctx.browseBase(), created.key())); } + @Override + public List searchImportableIssues(ProviderCredentials c, String projectKey, String issueTypeName) { + JiraApiContext ctx = contextFor(c); + String jql = "project = \"" + projectKey + "\" AND issuetype = \"" + issueTypeName + + "\" ORDER BY created ASC"; + return jira.searchAllIssues(ctx, jql).stream() + .map(JiraProvider::toRemoteIssue) + .toList(); + } + + private static RemoteIssue toRemoteIssue(JiraClient.JiraIssue issue) { + JiraClient.IssueFields f = issue.fields(); + String summary = f != null && f.summary() != null ? f.summary() : issue.key(); + String description = f != null ? JiraAdfReader.toPlainText(f.description()) : ""; + String issueType = f != null && f.issuetype() != null ? f.issuetype().name() : null; + String priority = mapPriority(f != null && f.priority() != null ? f.priority().name() : null); + return new RemoteIssue(issue.key(), summary, issueType, description, priority); + } + + /** + * Maps a Jira priority name to a Reqs-AI {@code Priority} name: Highest/High → HIGH, + * Medium → MEDIUM, Low/Lowest → LOW; anything else (including {@code null}) → MEDIUM. + */ + private static String mapPriority(String jiraPriority) { + if (jiraPriority == null) { + return "MEDIUM"; + } + return switch (jiraPriority.trim().toLowerCase(java.util.Locale.ROOT)) { + case "highest", "high" -> "HIGH"; + case "low", "lowest" -> "LOW"; + default -> "MEDIUM"; + }; + } + /** Builds the base-URL + auth context for the credential's mode. */ private static JiraApiContext contextFor(ProviderCredentials c) { if (c.credentialType() == CredentialType.OAUTH2) { From 1ba9a52ec281a54e4ed199e52987b4cac1dd68dc Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Tue, 7 Jul 2026 11:51:58 -0500 Subject: [PATCH 46/72] feat(gateway): import Jira issues as stories via the discovery write port MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JiraImportService resolves the target's provider/credentials (reusing StoryPushService.contextFor), fetches the eligible issues and maps each to a story through the discovery DiscoveryStoryWritePort (which owns the LLM transform + dedup). The command handler imports all — or only the requested issueKeys — capturing per-issue failures without aborting the batch and counting duplicates as skipped; the query handler previews candidates and flags likely duplicates without creating anything. Both 409 when the project has no configured integration target. --- .../ImportJiraStoriesCommandHandler.java | 51 +++++++++++++++ .../PreviewJiraImportQueryHandler.java | 47 ++++++++++++++ .../service/JiraImportService.java | 65 +++++++++++++++++++ 3 files changed, 163 insertions(+) create mode 100644 src/main/java/com/kntro/reqsai/gateway/application/handler/ImportJiraStoriesCommandHandler.java create mode 100644 src/main/java/com/kntro/reqsai/gateway/application/handler/PreviewJiraImportQueryHandler.java create mode 100644 src/main/java/com/kntro/reqsai/gateway/application/service/JiraImportService.java diff --git a/src/main/java/com/kntro/reqsai/gateway/application/handler/ImportJiraStoriesCommandHandler.java b/src/main/java/com/kntro/reqsai/gateway/application/handler/ImportJiraStoriesCommandHandler.java new file mode 100644 index 00000000..c69e0dec --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/handler/ImportJiraStoriesCommandHandler.java @@ -0,0 +1,51 @@ +package com.kntro.reqsai.gateway.application.handler; + +import com.kntro.reqsai.gateway.application.command.ImportJiraStoriesCommand; +import com.kntro.reqsai.gateway.application.port.IntegrationProvider.RemoteIssue; +import com.kntro.reqsai.gateway.application.port.ProjectIntegrationTargetRepository; +import com.kntro.reqsai.gateway.application.result.BatchImportResult; +import com.kntro.reqsai.gateway.application.result.ImportStoryResult; +import com.kntro.reqsai.gateway.application.service.JiraImportService; +import com.kntro.reqsai.gateway.application.service.StoryPushService.PushContext; +import com.kntro.reqsai.gateway.domain.exception.IntegrationsExceptions; +import com.kntro.reqsai.gateway.domain.model.ProjectIntegrationTarget; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +/** + * Pulls Jira issues from the project's configured target and creates them as user stories via the discovery + * write port (which owns the LLM mapping + dedup). 409 ({@code INTEGRATION_TARGET_NOT_CONFIGURED}) when no + * target exists. Per-issue failures are captured without aborting the batch; duplicates are counted as + * {@code skipped}. When {@code issueKeys} is null/empty, all eligible issues are imported. + */ +@Component +@RequiredArgsConstructor +public class ImportJiraStoriesCommandHandler { + + private final ProjectIntegrationTargetRepository targets; + private final JiraImportService importService; + + @Transactional + public BatchImportResult handle(ImportJiraStoriesCommand command) { + ProjectIntegrationTarget target = targets.findByProjectId(command.projectId()) + .orElseThrow(() -> IntegrationsExceptions.targetNotConfigured(command.projectId())); + + PushContext ctx = importService.contextFor(target); + List issues = importService.fetchIssues(ctx); + + Set requested = command.issueKeys() == null ? Set.of() : Set.copyOf(command.issueKeys()); + List results = new ArrayList<>(); + for (RemoteIssue issue : issues) { + if (!requested.isEmpty() && !requested.contains(issue.issueKey())) { + continue; + } + results.add(importService.importIssue(command.projectId(), issue)); + } + return BatchImportResult.of(results); + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/handler/PreviewJiraImportQueryHandler.java b/src/main/java/com/kntro/reqsai/gateway/application/handler/PreviewJiraImportQueryHandler.java new file mode 100644 index 00000000..a9452f13 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/handler/PreviewJiraImportQueryHandler.java @@ -0,0 +1,47 @@ +package com.kntro.reqsai.gateway.application.handler; + +import com.kntro.reqsai.discovery.api.StoryDuplicateCheck; +import com.kntro.reqsai.gateway.application.port.IntegrationProvider.RemoteIssue; +import com.kntro.reqsai.gateway.application.port.ProjectIntegrationTargetRepository; +import com.kntro.reqsai.gateway.application.query.PreviewJiraImportQuery; +import com.kntro.reqsai.gateway.application.result.ImportPreview; +import com.kntro.reqsai.gateway.application.result.ImportPreview.Candidate; +import com.kntro.reqsai.gateway.application.service.JiraImportService; +import com.kntro.reqsai.gateway.application.service.StoryPushService.PushContext; +import com.kntro.reqsai.gateway.domain.exception.IntegrationsExceptions; +import com.kntro.reqsai.gateway.domain.model.ProjectIntegrationTarget; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * Lists the candidate Jira issues eligible for import from the project's target and flags likely duplicates + * via the discovery similarity path — WITHOUT creating anything. 409 + * ({@code INTEGRATION_TARGET_NOT_CONFIGURED}) when no target exists. + */ +@Component +@RequiredArgsConstructor +public class PreviewJiraImportQueryHandler { + + private final ProjectIntegrationTargetRepository targets; + private final JiraImportService importService; + + @Transactional(readOnly = true) + public ImportPreview handle(PreviewJiraImportQuery query) { + ProjectIntegrationTarget target = targets.findByProjectId(query.projectId()) + .orElseThrow(() -> IntegrationsExceptions.targetNotConfigured(query.projectId())); + + PushContext ctx = importService.contextFor(target); + List issues = importService.fetchIssues(ctx); + + List candidates = issues.stream().map(issue -> { + StoryDuplicateCheck dup = importService.checkDuplicate(query.projectId(), issue); + return new Candidate(issue.issueKey(), issue.summary(), issue.issueType(), + dup.duplicate(), dup.existingStoryId()); + }).toList(); + + return ImportPreview.of(candidates); + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/service/JiraImportService.java b/src/main/java/com/kntro/reqsai/gateway/application/service/JiraImportService.java new file mode 100644 index 00000000..ffe6d818 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/service/JiraImportService.java @@ -0,0 +1,65 @@ +package com.kntro.reqsai.gateway.application.service; + +import com.kntro.reqsai.discovery.api.DiscoveryStoryWritePort; +import com.kntro.reqsai.discovery.api.ExternalIssueInput; +import com.kntro.reqsai.discovery.api.ImportedStory; +import com.kntro.reqsai.gateway.application.port.IntegrationProvider.RemoteIssue; +import com.kntro.reqsai.gateway.application.result.ImportStoryResult; +import com.kntro.reqsai.gateway.application.service.StoryPushService.PushContext; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.UUID; + +/** + * Shared Jira-import mechanics used by both the import command handler and the preview query handler: + * resolves the target's provider/credentials once (reusing {@link StoryPushService#contextFor}), fetches + * the eligible issues from Jira, and — for the import path — maps each issue to a story via the discovery + * {@link DiscoveryStoryWritePort} (which owns the LLM transformation + dedup). The connection/target model + * is unchanged: import pulls from the same {@code project_integration_targets} row push writes to. + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class JiraImportService { + + private final StoryPushService pushService; + private final DiscoveryStoryWritePort discoveryStories; + + /** Resolves the provider context (connection + credentials + project/issue-type) for the target. */ + public PushContext contextFor(com.kntro.reqsai.gateway.domain.model.ProjectIntegrationTarget target) { + return pushService.contextFor(target); + } + + /** Fetches every eligible Jira issue for the resolved target (all pages). */ + public java.util.List fetchIssues(PushContext ctx) { + return ctx.provider().searchImportableIssues(ctx.credentials(), ctx.projectKey(), ctx.issueTypeName()); + } + + /** + * Imports one Jira issue into the project as a story via the discovery write port. A near-duplicate is + * reported as {@link ImportStoryResult.Status#DUPLICATE} (skipped, nothing created). Any failure is + * captured as {@link ImportStoryResult.Status#FAILED} with a token-free message so the batch continues. + */ + public ImportStoryResult importIssue(UUID projectId, RemoteIssue issue) { + try { + ExternalIssueInput input = new ExternalIssueInput( + projectId, issue.summary(), issue.description(), null); + ImportedStory outcome = discoveryStories.importFromExternalIssue(input); + if (outcome.isDuplicate()) { + return ImportStoryResult.duplicate(issue.issueKey()); + } + return ImportStoryResult.imported(issue.issueKey(), outcome.storyId()); + } catch (RuntimeException e) { + log.warn("Import failed for Jira issue {}: {}", issue.issueKey(), e.getMessage()); + return ImportStoryResult.failed(issue.issueKey(), e.getMessage()); + } + } + + /** Checks whether a Jira issue would map to a near-duplicate, without creating anything (preview). */ + public com.kntro.reqsai.discovery.api.StoryDuplicateCheck checkDuplicate(UUID projectId, RemoteIssue issue) { + return discoveryStories.checkDuplicate(new ExternalIssueInput( + projectId, issue.summary(), issue.description(), null)); + } +} From ef4244ae3c21c18ad7f951559b36993ca6a67f26 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Tue, 7 Jul 2026 11:52:10 -0500 Subject: [PATCH 47/72] feat(gateway): expose Jira import preview and import endpoints Adds the preview (GET) and import (POST, optional issueKeys) endpoints on the project integration controller, both guarded by INTEGRATION_SYNC, with the request/response DTOs (ImportJiraStoriesRequest, JiraImportPreviewResponse, JiraImportResponse) and the response mapper wiring for the import results. --- .../ProjectIntegrationControllerImpl.java | 28 ++++++++++++++ .../dto/request/ImportJiraStoriesRequest.java | 16 ++++++++ .../response/JiraImportPreviewResponse.java | 20 ++++++++++ .../rest/dto/response/JiraImportResponse.java | 19 ++++++++++ .../response/IntegrationResponseMapper.java | 26 +++++++++++++ .../swagger/ProjectIntegrationController.java | 37 +++++++++++++++++++ 6 files changed, 146 insertions(+) create mode 100644 src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/request/ImportJiraStoriesRequest.java create mode 100644 src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/JiraImportPreviewResponse.java create mode 100644 src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/JiraImportResponse.java diff --git a/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/controllers/ProjectIntegrationControllerImpl.java b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/controllers/ProjectIntegrationControllerImpl.java index 1e217db3..0b38cefa 100644 --- a/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/controllers/ProjectIntegrationControllerImpl.java +++ b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/controllers/ProjectIntegrationControllerImpl.java @@ -1,16 +1,23 @@ package com.kntro.reqsai.gateway.interfaces.rest.controllers; import com.kntro.reqsai.gateway.application.command.DeleteProjectTargetCommand; +import com.kntro.reqsai.gateway.application.command.ImportJiraStoriesCommand; import com.kntro.reqsai.gateway.application.command.PushAllStoriesCommand; import com.kntro.reqsai.gateway.application.command.PushStoryCommand; import com.kntro.reqsai.gateway.application.handler.DeleteProjectTargetCommandHandler; import com.kntro.reqsai.gateway.application.handler.GetProjectTargetQueryHandler; +import com.kntro.reqsai.gateway.application.handler.ImportJiraStoriesCommandHandler; +import com.kntro.reqsai.gateway.application.handler.PreviewJiraImportQueryHandler; import com.kntro.reqsai.gateway.application.handler.PushAllStoriesCommandHandler; import com.kntro.reqsai.gateway.application.handler.PushStoryCommandHandler; import com.kntro.reqsai.gateway.application.handler.SaveProjectTargetCommandHandler; import com.kntro.reqsai.gateway.application.query.GetProjectTargetQuery; +import com.kntro.reqsai.gateway.application.query.PreviewJiraImportQuery; +import com.kntro.reqsai.gateway.interfaces.rest.dto.request.ImportJiraStoriesRequest; import com.kntro.reqsai.gateway.interfaces.rest.dto.request.SaveProjectTargetRequest; import com.kntro.reqsai.gateway.interfaces.rest.dto.response.BatchPushResponse; +import com.kntro.reqsai.gateway.interfaces.rest.dto.response.JiraImportPreviewResponse; +import com.kntro.reqsai.gateway.interfaces.rest.dto.response.JiraImportResponse; import com.kntro.reqsai.gateway.interfaces.rest.dto.response.JiraPushResultResponse; import com.kntro.reqsai.gateway.interfaces.rest.dto.response.ProjectJiraTargetResponse; import com.kntro.reqsai.gateway.interfaces.rest.mappers.request.IntegrationRequestMapper; @@ -22,6 +29,7 @@ import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.RestController; +import java.util.List; import java.util.UUID; /** @@ -38,6 +46,8 @@ public class ProjectIntegrationControllerImpl implements ProjectIntegrationContr private final DeleteProjectTargetCommandHandler deleteTarget; private final PushStoryCommandHandler pushStory; private final PushAllStoriesCommandHandler pushAllStories; + private final PreviewJiraImportQueryHandler previewImport; + private final ImportJiraStoriesCommandHandler importStories; @Override @PreAuthorize("@authz.projectPermission(#projectId, 'INTEGRATION_READ', authentication)") @@ -79,4 +89,22 @@ public ResponseEntity pushAllStories(UUID projectId, Authenti return ResponseEntity.ok(IntegrationResponseMapper.toResponse( pushAllStories.handle(new PushAllStoriesCommand(projectId, requestedBy)))); } + + @Override + @PreAuthorize("@authz.projectPermission(#projectId, 'INTEGRATION_SYNC', authentication)") + public ResponseEntity previewImport(UUID projectId, Authentication authentication) { + UUID requestedBy = UUID.fromString(authentication.getName()); + return ResponseEntity.ok(IntegrationResponseMapper.toResponse( + previewImport.handle(new PreviewJiraImportQuery(projectId, requestedBy)))); + } + + @Override + @PreAuthorize("@authz.projectPermission(#projectId, 'INTEGRATION_SYNC', authentication)") + public ResponseEntity importStories( + UUID projectId, ImportJiraStoriesRequest request, Authentication authentication) { + UUID requestedBy = UUID.fromString(authentication.getName()); + List issueKeys = request == null ? null : request.issueKeys(); + return ResponseEntity.ok(IntegrationResponseMapper.toResponse( + importStories.handle(new ImportJiraStoriesCommand(projectId, issueKeys, requestedBy)))); + } } diff --git a/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/request/ImportJiraStoriesRequest.java b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/request/ImportJiraStoriesRequest.java new file mode 100644 index 00000000..14371c6d --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/request/ImportJiraStoriesRequest.java @@ -0,0 +1,16 @@ +package com.kntro.reqsai.gateway.interfaces.rest.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import org.jspecify.annotations.Nullable; + +import java.util.List; + +/** + * Request body for the Jira import. {@code issueKeys} restricts the import to the given issue keys; omit or + * leave empty to import every eligible issue of the project's target. + */ +@Schema(description = "Request body to import Jira issues as user stories") +public record ImportJiraStoriesRequest( + @Schema(description = "Specific Jira issue keys to import; omit/empty = all eligible", example = "[\"PAY-1\",\"PAY-2\"]") + @Nullable List issueKeys +) {} diff --git a/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/JiraImportPreviewResponse.java b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/JiraImportPreviewResponse.java new file mode 100644 index 00000000..5e8c9519 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/JiraImportPreviewResponse.java @@ -0,0 +1,20 @@ +package com.kntro.reqsai.gateway.interfaces.rest.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import org.jspecify.annotations.Nullable; + +import java.util.List; +import java.util.UUID; + +/** Preview of a Jira import: candidate issues with likely-duplicate flags. */ +@Schema(description = "Candidate Jira issues eligible for import, with likely-duplicate flags") +public record JiraImportPreviewResponse(int total, List issues) { + + @Schema(description = "One candidate Jira issue") + public record Candidate( + String jiraIssueKey, + String summary, + @Nullable String issueType, + boolean duplicate, + @Nullable UUID existingStoryId) {} +} diff --git a/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/JiraImportResponse.java b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/JiraImportResponse.java new file mode 100644 index 00000000..50a327f8 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/JiraImportResponse.java @@ -0,0 +1,19 @@ +package com.kntro.reqsai.gateway.interfaces.rest.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import org.jspecify.annotations.Nullable; + +import java.util.List; +import java.util.UUID; + +/** Result of a Jira import: per-issue results plus imported/skipped/failed counts. */ +@Schema(description = "Result of importing Jira issues as user stories") +public record JiraImportResponse(int imported, int skipped, int failed, List results) { + + @Schema(description = "Per-issue import result; status is imported | duplicate | failed") + public record Result( + String jiraIssueKey, + @Nullable UUID storyId, + String status, + @Nullable String message) {} +} diff --git a/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/mappers/response/IntegrationResponseMapper.java b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/mappers/response/IntegrationResponseMapper.java index 61ddd6bc..ba9648b1 100644 --- a/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/mappers/response/IntegrationResponseMapper.java +++ b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/mappers/response/IntegrationResponseMapper.java @@ -3,8 +3,11 @@ import com.kntro.reqsai.gateway.application.port.IntegrationProvider.RemoteIssueType; import com.kntro.reqsai.gateway.application.port.IntegrationProvider.RemoteProject; import com.kntro.reqsai.gateway.application.port.JiraOAuthPort.Site; +import com.kntro.reqsai.gateway.application.result.BatchImportResult; import com.kntro.reqsai.gateway.application.result.BatchPushResult; import com.kntro.reqsai.gateway.application.result.ConnectionTestResult; +import com.kntro.reqsai.gateway.application.result.ImportPreview; +import com.kntro.reqsai.gateway.application.result.ImportStoryResult; import com.kntro.reqsai.gateway.application.result.StoryPushResult; import com.kntro.reqsai.gateway.domain.model.IntegrationConnection; import com.kntro.reqsai.gateway.domain.model.ProjectIntegrationTarget; @@ -13,6 +16,8 @@ import com.kntro.reqsai.gateway.interfaces.rest.dto.response.IntegrationConnectionResponse; import com.kntro.reqsai.gateway.interfaces.rest.dto.response.JiraIssueTypeResponse; import com.kntro.reqsai.gateway.interfaces.rest.dto.response.JiraOAuthSiteResponse; +import com.kntro.reqsai.gateway.interfaces.rest.dto.response.JiraImportPreviewResponse; +import com.kntro.reqsai.gateway.interfaces.rest.dto.response.JiraImportResponse; import com.kntro.reqsai.gateway.interfaces.rest.dto.response.JiraProjectResponse; import com.kntro.reqsai.gateway.interfaces.rest.dto.response.JiraPushResultResponse; import com.kntro.reqsai.gateway.interfaces.rest.dto.response.ProjectJiraTargetResponse; @@ -75,4 +80,25 @@ public static BatchPushResponse toResponse(BatchPushResult r) { r.pushed(), r.failed()); } + + public static JiraImportPreviewResponse toResponse(ImportPreview p) { + return new JiraImportPreviewResponse( + p.total(), + p.issues().stream() + .map(c -> new JiraImportPreviewResponse.Candidate( + c.jiraIssueKey(), c.summary(), c.issueType(), c.duplicate(), c.existingStoryId())) + .toList()); + } + + public static JiraImportResponse toResponse(BatchImportResult r) { + return new JiraImportResponse( + r.imported(), + r.skipped(), + r.failed(), + r.results().stream().map(IntegrationResponseMapper::toResponse).toList()); + } + + public static JiraImportResponse.Result toResponse(ImportStoryResult r) { + return new JiraImportResponse.Result(r.jiraIssueKey(), r.storyId(), r.status().wire(), r.message()); + } } diff --git a/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/swagger/ProjectIntegrationController.java b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/swagger/ProjectIntegrationController.java index 8e20b05b..f393a0a5 100644 --- a/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/swagger/ProjectIntegrationController.java +++ b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/swagger/ProjectIntegrationController.java @@ -1,7 +1,10 @@ package com.kntro.reqsai.gateway.interfaces.rest.swagger; +import com.kntro.reqsai.gateway.interfaces.rest.dto.request.ImportJiraStoriesRequest; import com.kntro.reqsai.gateway.interfaces.rest.dto.request.SaveProjectTargetRequest; import com.kntro.reqsai.gateway.interfaces.rest.dto.response.BatchPushResponse; +import com.kntro.reqsai.gateway.interfaces.rest.dto.response.JiraImportPreviewResponse; +import com.kntro.reqsai.gateway.interfaces.rest.dto.response.JiraImportResponse; import com.kntro.reqsai.gateway.interfaces.rest.dto.response.JiraPushResultResponse; import com.kntro.reqsai.gateway.interfaces.rest.dto.response.ProjectJiraTargetResponse; import com.kntro.reqsai.shared.infrastructure.configuration.ApiVersioning; @@ -105,4 +108,38 @@ ResponseEntity pushStory( ResponseEntity pushAllStories( @Parameter(description = "Project UUID") @PathVariable UUID projectId, Authentication authentication); + + @Operation(summary = "Preview a Jira import", + description = """ + Lists the Jira issues eligible for import from the project's target and flags likely + duplicates (detected via the discovery similarity path, without creating anything). + 409 when no target is configured.""") + @ApiResponse(responseCode = "200", description = "Import preview", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = JiraImportPreviewResponse.class))) + @ApiResponseConflict + @ApiStandardErrorResponses + @SecurityRequirement(name = OpenApiConfiguration.BEARER_SCHEME) + @GetMapping(value = "/import/preview", version = ApiVersioning.V1) + ResponseEntity previewImport( + @Parameter(description = "Project UUID") @PathVariable UUID projectId, + Authentication authentication); + + @Operation(summary = "Import Jira issues as stories", + description = """ + Pulls Jira issues from the project's target and creates them as user stories (LLM + mapping + duplicate detection reused from discovery). Body {issueKeys?} restricts the + import; omit/empty imports all eligible issues. Per-issue failures are captured without + aborting the batch; duplicates are counted as skipped. 409 when no target is configured.""") + @ApiResponse(responseCode = "200", description = "Import result", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = JiraImportResponse.class))) + @ApiResponseConflict + @ApiStandardErrorResponses + @SecurityRequirement(name = OpenApiConfiguration.BEARER_SCHEME) + @PostMapping(value = "/import", version = ApiVersioning.V1) + ResponseEntity importStories( + @Parameter(description = "Project UUID") @PathVariable UUID projectId, + @RequestBody(required = false) ImportJiraStoriesRequest request, + Authentication authentication); } From f213c20de3dde0eb7c92caa5b836f91c7f8b702a Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Tue, 7 Jul 2026 11:52:18 -0500 Subject: [PATCH 48/72] test(gateway): cover Jira import preview, batch and issue mapping Handler tests for import (all/selected keys, duplicates skipped, per-issue failures not aborting the batch, 409 when no target) and preview (duplicate flagging); a JiraProvider mapping test for the ADF/priority flattening; and an end-to-end REST integration test. Extends StubJiraProviderConfig with the importable-issues stub. --- .../gateway/StubJiraProviderConfig.java | 10 + .../ImportJiraStoriesCommandHandlerTest.java | 110 +++++++++++ .../PreviewJiraImportQueryHandlerTest.java | 74 ++++++++ .../jira/JiraProviderImportMappingTest.java | 67 +++++++ .../rest/JiraImportIntegrationTest.java | 176 ++++++++++++++++++ 5 files changed, 437 insertions(+) create mode 100644 src/test/java/com/kntro/reqsai/gateway/application/handler/ImportJiraStoriesCommandHandlerTest.java create mode 100644 src/test/java/com/kntro/reqsai/gateway/application/handler/PreviewJiraImportQueryHandlerTest.java create mode 100644 src/test/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraProviderImportMappingTest.java create mode 100644 src/test/java/com/kntro/reqsai/gateway/interfaces/rest/JiraImportIntegrationTest.java diff --git a/src/test/java/com/kntro/reqsai/gateway/StubJiraProviderConfig.java b/src/test/java/com/kntro/reqsai/gateway/StubJiraProviderConfig.java index 0e8c56dd..c3fe8a40 100644 --- a/src/test/java/com/kntro/reqsai/gateway/StubJiraProviderConfig.java +++ b/src/test/java/com/kntro/reqsai/gateway/StubJiraProviderConfig.java @@ -49,6 +49,16 @@ public PushedIssue pushStory(ProviderCredentials c, String projectKey, String is String key = projectKey + "-" + Math.abs(story.storyId().hashCode() % 1000); return new PushedIssue(key, c.siteUrl() + "/browse/" + key); } + + @Override + public List searchImportableIssues(ProviderCredentials c, String projectKey, String issueTypeName) { + return List.of( + new RemoteIssue(projectKey + "-101", "Password reset via email", + issueTypeName, "As a user I want to reset my password so that I can regain access.", + "HIGH"), + new RemoteIssue(projectKey + "-102", "Export backlog to CSV", + issueTypeName, "Allow exporting the backlog as a CSV file.", "MEDIUM")); + } }; } } diff --git a/src/test/java/com/kntro/reqsai/gateway/application/handler/ImportJiraStoriesCommandHandlerTest.java b/src/test/java/com/kntro/reqsai/gateway/application/handler/ImportJiraStoriesCommandHandlerTest.java new file mode 100644 index 00000000..e370101b --- /dev/null +++ b/src/test/java/com/kntro/reqsai/gateway/application/handler/ImportJiraStoriesCommandHandlerTest.java @@ -0,0 +1,110 @@ +package com.kntro.reqsai.gateway.application.handler; + +import com.kntro.reqsai.gateway.application.command.ImportJiraStoriesCommand; +import com.kntro.reqsai.gateway.application.port.IntegrationProvider.RemoteIssue; +import com.kntro.reqsai.gateway.application.port.ProjectIntegrationTargetRepository; +import com.kntro.reqsai.gateway.application.result.BatchImportResult; +import com.kntro.reqsai.gateway.application.result.ImportStoryResult; +import com.kntro.reqsai.gateway.application.service.JiraImportService; +import com.kntro.reqsai.gateway.application.service.StoryPushService.PushContext; +import com.kntro.reqsai.gateway.domain.model.ProjectIntegrationTarget; +import com.kntro.reqsai.shared.domain.exception.DomainException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@Tag("unit") +@ExtendWith(MockitoExtension.class) +@DisplayName("Application: Import Jira stories command handler") +class ImportJiraStoriesCommandHandlerTest { + + private static final UUID PROJECT = UUID.randomUUID(); + private static final UUID USER = UUID.randomUUID(); + + @Mock + private ProjectIntegrationTargetRepository targets; + @Mock + private JiraImportService importService; + @InjectMocks + private ImportJiraStoriesCommandHandler handler; + + @Test + @DisplayName("imports each eligible issue, counting imported vs duplicate vs failed") + void imports_counts_outcomes() { + stubTargetAndIssues(List.of( + issue("PAY-1"), issue("PAY-2"), issue("PAY-3"))); + UUID storyId = UUID.randomUUID(); + when(importService.importIssue(eq(PROJECT), argKey("PAY-1"))) + .thenReturn(ImportStoryResult.imported("PAY-1", storyId)); + when(importService.importIssue(eq(PROJECT), argKey("PAY-2"))) + .thenReturn(ImportStoryResult.duplicate("PAY-2")); + when(importService.importIssue(eq(PROJECT), argKey("PAY-3"))) + .thenReturn(ImportStoryResult.failed("PAY-3", "Jira import failed: boom")); + + BatchImportResult result = handler.handle(new ImportJiraStoriesCommand(PROJECT, null, USER)); + + assertThat(result.imported()).isEqualTo(1); + assertThat(result.skipped()).isEqualTo(1); + assertThat(result.failed()).isEqualTo(1); + assertThat(result.results()).hasSize(3); + } + + @Test + @DisplayName("issueKeys restricts the import to the requested keys") + void issue_keys_filter() { + stubTargetAndIssues(List.of(issue("PAY-1"), issue("PAY-2"))); + when(importService.importIssue(eq(PROJECT), argKey("PAY-2"))) + .thenReturn(ImportStoryResult.imported("PAY-2", UUID.randomUUID())); + + BatchImportResult result = handler.handle( + new ImportJiraStoriesCommand(PROJECT, List.of("PAY-2"), USER)); + + assertThat(result.imported()).isEqualTo(1); + assertThat(result.results()).extracting(ImportStoryResult::jiraIssueKey).containsExactly("PAY-2"); + verify(importService, never()).importIssue(eq(PROJECT), argKey("PAY-1")); + } + + @Test + @DisplayName("409 INTEGRATION_TARGET_NOT_CONFIGURED when no target exists") + void no_target_conflicts() { + when(targets.findByProjectId(PROJECT)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> handler.handle(new ImportJiraStoriesCommand(PROJECT, null, USER))) + .isInstanceOf(DomainException.class) + .satisfies(e -> assertThat(((DomainException) e).error().code()) + .isEqualTo("INTEGRATION_TARGET_NOT_CONFIGURED")); + } + + private void stubTargetAndIssues(List issues) { + ProjectIntegrationTarget target = mock(ProjectIntegrationTarget.class); + when(targets.findByProjectId(PROJECT)).thenReturn(Optional.of(target)); + PushContext ctx = mock(PushContext.class); + when(importService.contextFor(target)).thenReturn(ctx); + when(importService.fetchIssues(ctx)).thenReturn(issues); + } + + private static RemoteIssue issue(String key) { + return new RemoteIssue(key, "Summary " + key, "Story", "desc", "MEDIUM"); + } + + private static RemoteIssue argKey(String key) { + return org.mockito.ArgumentMatchers.argThat(i -> i != null && key.equals(i.issueKey())); + } +} diff --git a/src/test/java/com/kntro/reqsai/gateway/application/handler/PreviewJiraImportQueryHandlerTest.java b/src/test/java/com/kntro/reqsai/gateway/application/handler/PreviewJiraImportQueryHandlerTest.java new file mode 100644 index 00000000..4b56bf0f --- /dev/null +++ b/src/test/java/com/kntro/reqsai/gateway/application/handler/PreviewJiraImportQueryHandlerTest.java @@ -0,0 +1,74 @@ +package com.kntro.reqsai.gateway.application.handler; + +import com.kntro.reqsai.discovery.api.StoryDuplicateCheck; +import com.kntro.reqsai.gateway.application.port.IntegrationProvider.RemoteIssue; +import com.kntro.reqsai.gateway.application.port.ProjectIntegrationTargetRepository; +import com.kntro.reqsai.gateway.application.query.PreviewJiraImportQuery; +import com.kntro.reqsai.gateway.application.result.ImportPreview; +import com.kntro.reqsai.gateway.application.service.JiraImportService; +import com.kntro.reqsai.gateway.application.service.StoryPushService.PushContext; +import com.kntro.reqsai.gateway.domain.model.ProjectIntegrationTarget; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@Tag("unit") +@ExtendWith(MockitoExtension.class) +@DisplayName("Application: Preview Jira import query handler") +class PreviewJiraImportQueryHandlerTest { + + private static final UUID PROJECT = UUID.randomUUID(); + private static final UUID USER = UUID.randomUUID(); + + @Mock + private ProjectIntegrationTargetRepository targets; + @Mock + private JiraImportService importService; + @InjectMocks + private PreviewJiraImportQueryHandler handler; + + @Test + @DisplayName("flags likely duplicates without importing, and reports the total") + void flags_duplicates() { + ProjectIntegrationTarget target = mock(ProjectIntegrationTarget.class); + when(targets.findByProjectId(PROJECT)).thenReturn(Optional.of(target)); + PushContext ctx = mock(PushContext.class); + when(importService.contextFor(target)).thenReturn(ctx); + RemoteIssue a = new RemoteIssue("PAY-1", "Login", "Story", "desc", "HIGH"); + RemoteIssue b = new RemoteIssue("PAY-2", "Logout", "Story", "desc", "LOW"); + when(importService.fetchIssues(ctx)).thenReturn(List.of(a, b)); + UUID existing = UUID.randomUUID(); + when(importService.checkDuplicate(eq(PROJECT), argKey("PAY-1"))) + .thenReturn(new StoryDuplicateCheck(true, existing, 0.9)); + when(importService.checkDuplicate(eq(PROJECT), argKey("PAY-2"))) + .thenReturn(StoryDuplicateCheck.notDuplicate()); + + ImportPreview preview = handler.handle(new PreviewJiraImportQuery(PROJECT, USER)); + + assertThat(preview.total()).isEqualTo(2); + assertThat(preview.issues()).hasSize(2); + ImportPreview.Candidate first = preview.issues().getFirst(); + assertThat(first.jiraIssueKey()).isEqualTo("PAY-1"); + assertThat(first.duplicate()).isTrue(); + assertThat(first.existingStoryId()).isEqualTo(existing); + assertThat(preview.issues().get(1).duplicate()).isFalse(); + } + + private static RemoteIssue argKey(String key) { + return org.mockito.ArgumentMatchers.argThat(i -> i != null && key.equals(i.issueKey())); + } +} diff --git a/src/test/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraProviderImportMappingTest.java b/src/test/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraProviderImportMappingTest.java new file mode 100644 index 00000000..5a7b8506 --- /dev/null +++ b/src/test/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraProviderImportMappingTest.java @@ -0,0 +1,67 @@ +package com.kntro.reqsai.gateway.infrastructure.jira; + +import com.kntro.reqsai.gateway.application.port.IntegrationProvider.ProviderCredentials; +import com.kntro.reqsai.gateway.application.port.IntegrationProvider.RemoteIssue; +import com.kntro.reqsai.gateway.infrastructure.jira.JiraClient.IssueFields; +import com.kntro.reqsai.gateway.infrastructure.jira.JiraClient.JiraApiContext; +import com.kntro.reqsai.gateway.infrastructure.jira.JiraClient.JiraIssue; +import com.kntro.reqsai.gateway.infrastructure.jira.JiraClient.NamedRef; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +@Tag("unit") +@DisplayName("Infrastructure: Jira issue -> RemoteIssue import mapping (priority + ADF flatten)") +class JiraProviderImportMappingTest { + + private static final ProviderCredentials CREDS = + ProviderCredentials.apiToken("https://acme.atlassian.net", "pm@acme.com", "tok"); + + @Test + @DisplayName("maps summary, ADF description (flattened), issue type and priority scale") + void maps_issue_fields() { + Map adf = Map.of( + "type", "doc", "version", 1, + "content", List.of( + Map.of("type", "paragraph", "content", + List.of(Map.of("type", "text", "text", "As a user I want to reset my password."))))); + JiraIssue high = new JiraIssue("PAY-1", + new IssueFields("Password reset", adf, new NamedRef("10001", "Historia"), new NamedRef("2", "High"))); + JiraIssue lowest = new JiraIssue("PAY-2", + new IssueFields("Tidy up", null, new NamedRef("10002", "Tarea"), new NamedRef("5", "Lowest"))); + JiraIssue noPriority = new JiraIssue("PAY-3", + new IssueFields("No priority", null, new NamedRef("10001", "Historia"), null)); + + JiraProvider provider = new JiraProvider(new StubSearchClient(List.of(high, lowest, noPriority))); + List issues = provider.searchImportableIssues(CREDS, "PAY", "Historia"); + + assertThat(issues).hasSize(3); + assertThat(issues.get(0).issueKey()).isEqualTo("PAY-1"); + assertThat(issues.get(0).summary()).isEqualTo("Password reset"); + assertThat(issues.get(0).description()).contains("reset my password"); + assertThat(issues.get(0).issueType()).isEqualTo("Historia"); + assertThat(issues.get(0).priority()).isEqualTo("HIGH"); + assertThat(issues.get(1).priority()).isEqualTo("LOW"); // Lowest -> LOW + assertThat(issues.get(1).description()).isEmpty(); // null ADF -> "" + assertThat(issues.get(2).priority()).isEqualTo("MEDIUM"); // no priority -> MEDIUM + } + + /** Minimal JiraClient stand-in that returns a fixed search result. */ + private static final class StubSearchClient extends JiraClient { + private final List result; + + private StubSearchClient(List result) { + this.result = result; + } + + @Override + public List searchAllIssues(JiraApiContext ctx, String jql) { + return result; + } + } +} diff --git a/src/test/java/com/kntro/reqsai/gateway/interfaces/rest/JiraImportIntegrationTest.java b/src/test/java/com/kntro/reqsai/gateway/interfaces/rest/JiraImportIntegrationTest.java new file mode 100644 index 00000000..575b3d57 --- /dev/null +++ b/src/test/java/com/kntro/reqsai/gateway/interfaces/rest/JiraImportIntegrationTest.java @@ -0,0 +1,176 @@ +package com.kntro.reqsai.gateway.interfaces.rest; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.kntro.reqsai.gateway.StubJiraProviderConfig; +import com.kntro.reqsai.testsupport.AbstractIntegrationTest; +import com.kntro.reqsai.testsupport.StubEmbeddingConfig; +import com.kntro.reqsai.testsupport.StubRequirementGenerationConfig; +import com.kntro.reqsai.testsupport.TestJwtFactory; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; + +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * End-to-end test of the Jira IMPORT slice: connects Jira at the org level, sets a project target, then + * imports the two stubbed Jira issues into the backlog as user stories — asserting stories are created and + * that a near-duplicate is skipped (both stubbed issues map to the same stubbed generation output, so the + * second is detected as a duplicate of the first via the deterministic embedding stub). + * + *

    The Jira boundary is stubbed via {@link StubJiraProviderConfig} (no real network) and the LLM via + * {@link StubRequirementGenerationConfig} (no real model — this test is NOT tagged {@code llm}). + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +@Import({StubJiraProviderConfig.class, StubEmbeddingConfig.class, StubRequirementGenerationConfig.class}) +@Tag("integration") +@DisplayName("Integration: Jira import (preview + import)") +class JiraImportIntegrationTest extends AbstractIntegrationTest { + + private static final String USER_ID = "00000000-0000-0000-0000-000000000001"; + private static final ObjectMapper JSON = new ObjectMapper(); + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Test + @DisplayName("previews candidates then imports: creates stories and skips a duplicate") + void previews_then_imports() throws Exception { + String suffix = UUID.randomUUID().toString().substring(0, 8); + String slug = "acme-" + suffix; + String schema = "tenant_" + slug; + String orgId = createOrg(suffix, slug); + UUID projectId = createProject(orgId, schema, "Payment Platform"); + + String connectionId = connectAndReadId(orgId, schema); + setTarget(orgId, projectId, connectionId); + + // Preview lists both stubbed issues; neither is a duplicate yet (empty backlog). + ResponseEntity previewRes = client().get() + .uri("/api/projects/{p}/integration/jira/import/preview", projectId) + .header("Authorization", TestJwtFactory.bearer(USER_ID, orgId, "ROLE_USER")) + .header("Api-Version", "1") + .exchange((req, res) -> ResponseEntity.status(res.getStatusCode()).body(res.bodyTo(String.class))); + assertThat(previewRes.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode preview = JSON.readTree(previewRes.getBody()); + assertThat(preview.get("total").asInt()).isEqualTo(2); + assertThat(preview.get("issues")).hasSize(2); + + // Import all eligible issues. + ResponseEntity importRes = client().post() + .uri("/api/projects/{p}/integration/jira/import", projectId) + .header("Authorization", TestJwtFactory.bearer(USER_ID, orgId, "ROLE_USER")) + .header("Api-Version", "1") + .contentType(MediaType.APPLICATION_JSON) + .body(Map.of()) + .exchange((req, res) -> ResponseEntity.status(res.getStatusCode()).body(res.bodyTo(String.class))); + assertThat(importRes.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode result = JSON.readTree(importRes.getBody()); + + // Both stubbed issues map (via the stubbed generation) to the same story, so the second collides. + assertThat(result.get("imported").asInt()).isEqualTo(1); + assertThat(result.get("skipped").asInt()).isEqualTo(1); + assertThat(result.get("failed").asInt()).isZero(); + assertThat(result.get("results")).hasSize(2); + boolean hasImported = false; + boolean hasDuplicate = false; + for (JsonNode r : result.get("results")) { + if ("imported".equals(r.get("status").asText())) { + hasImported = true; + assertThat(r.hasNonNull("storyId")).isTrue(); + } else if ("duplicate".equals(r.get("status").asText())) { + hasDuplicate = true; + assertThat(r.hasNonNull("storyId")).isFalse(); + } + } + assertThat(hasImported).isTrue(); + assertThat(hasDuplicate).isTrue(); + + // Exactly one story persisted in the tenant backlog. + Integer storyCount = jdbcTemplate.queryForObject( + "SELECT count(*) FROM \"" + schema + "\".user_stories WHERE project_id = ?::uuid", + Integer.class, projectId.toString()); + assertThat(storyCount).isEqualTo(1); + } + + @Test + @DisplayName("returns 409 when importing with no target configured") + void import_without_target() { + String suffix = UUID.randomUUID().toString().substring(0, 8); + String orgId = createOrg(suffix, "acme-" + suffix); + UUID projectId = UUID.randomUUID(); + + ResponseEntity res = client().post() + .uri("/api/projects/{p}/integration/jira/import", projectId) + .header("Authorization", TestJwtFactory.bearer(USER_ID, orgId, "ROLE_USER")) + .header("Api-Version", "1") + .contentType(MediaType.APPLICATION_JSON) + .body(Map.of()) + .exchange((req, response) -> ResponseEntity.status(response.getStatusCode()) + .body(response.bodyTo(String.class))); + + assertThat(res.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + assertThat(res.getBody()).contains("INTEGRATION_TARGET_NOT_CONFIGURED"); + } + + private String connectAndReadId(String orgId, String schema) throws Exception { + ResponseEntity connectRes = client().post() + .uri("/api/organizations/{orgId}/integrations/jira", orgId) + .header("Authorization", TestJwtFactory.bearer(USER_ID, orgId, "ROLE_USER")) + .header("Api-Version", "1") + .contentType(MediaType.APPLICATION_JSON) + .body(Map.of("siteUrl", "https://acme.atlassian.net", "email", "pm@acme.com", "apiToken", "tok")) + .exchange((req, res) -> ResponseEntity.status(res.getStatusCode()).body(res.bodyTo(String.class))); + assertThat(connectRes.getStatusCode()).isEqualTo(HttpStatus.CREATED); + return JSON.readTree(connectRes.getBody()).get("id").asText(); + } + + private void setTarget(String orgId, UUID projectId, String connectionId) { + ResponseEntity targetRes = client().put() + .uri("/api/projects/{p}/integration/jira/target", projectId) + .header("Authorization", TestJwtFactory.bearer(USER_ID, orgId, "ROLE_USER")) + .header("Api-Version", "1") + .contentType(MediaType.APPLICATION_JSON) + .body(Map.of("connectionId", connectionId, "jiraProjectKey", "PAY", "issueTypeName", "Story")) + .exchange((req, res) -> ResponseEntity.status(res.getStatusCode()).body(res.bodyTo(String.class))); + assertThat(targetRes.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + private UUID createProject(String orgId, String schema, String name) { + client().post().uri("/api/organizations/{orgId}/projects", orgId) + .header("Authorization", TestJwtFactory.bearer(USER_ID, orgId, "ROLE_USER")) + .header("Api-Version", "1") + .contentType(MediaType.APPLICATION_JSON) + .body(Map.of("name", name, "programmingLanguages", java.util.List.of("Java"), + "frameworks", java.util.List.of("Spring Boot"), "clientPlatforms", java.util.List.of("Web"), + "databases", java.util.List.of("PostgreSQL"), "architecture", "Hexagonal", "domain", "Fintech")) + .exchange((req, res) -> ResponseEntity.status(res.getStatusCode()).body(res.bodyTo(String.class))); + return UUID.fromString(jdbcTemplate.queryForObject( + "SELECT id::text FROM \"" + schema + "\".projects WHERE name = ?", String.class, name)); + } + + private String createOrg(String suffix, String expectedSlug) { + ResponseEntity orgRes = client().post().uri("/api/organizations") + .header("Authorization", TestJwtFactory.bearer(USER_ID, UUID.randomUUID().toString(), "ROLE_USER")) + .header("Api-Version", "1") + .contentType(MediaType.APPLICATION_JSON) + .body(Map.of("name", "Acme " + suffix)) + .exchange((req, res) -> ResponseEntity.status(res.getStatusCode()).body(res.bodyTo(String.class))); + assertThat(orgRes.getStatusCode()).isEqualTo(HttpStatus.CREATED); + return jdbcTemplate.queryForObject( + "SELECT id FROM public.organizations WHERE slug = ?", String.class, expectedSlug); + } +} From 9266021bc6a8112cad65ed5df3d4c0cf7e5a79e3 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Wed, 8 Jul 2026 00:48:31 -0500 Subject: [PATCH 49/72] test(discovery): drop unnecessary highestSimilarity stub DiscoveryStoryWritePortImpl only calls findMostSimilar for the dedup check; the highestSimilarity stub in duplicate_is_reported_not_created was never invoked and failed the suite under Mockito's strict stubbing (UnnecessaryStubbingException). --- .../application/service/DiscoveryStoryWritePortImplTest.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/test/java/com/kntro/reqsai/discovery/application/service/DiscoveryStoryWritePortImplTest.java b/src/test/java/com/kntro/reqsai/discovery/application/service/DiscoveryStoryWritePortImplTest.java index 59d0d88e..8b3dbf0b 100644 --- a/src/test/java/com/kntro/reqsai/discovery/application/service/DiscoveryStoryWritePortImplTest.java +++ b/src/test/java/com/kntro/reqsai/discovery/application/service/DiscoveryStoryWritePortImplTest.java @@ -119,7 +119,6 @@ void duplicate_is_reported_not_created() { when(generationPort.isAvailable()).thenReturn(false); when(embeddingPort.isAvailable()).thenReturn(true); when(embeddingPort.embed(any())).thenReturn(new float[EmbeddingPort.DIMENSIONS]); - when(stories.highestSimilarity(any(), any())).thenReturn(Optional.of(0.93)); when(stories.findMostSimilar(any(), any())) .thenReturn(Optional.of(new UserStoryRepository.SimilarStory(existing, 0.93))); From 9d5ee8dfeb93928d438df7e03f5809784582ff5a Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Wed, 8 Jul 2026 01:00:40 -0500 Subject: [PATCH 50/72] refactor(gateway): rename integration migrations to timestamp versions This branch predates the timestamp-based migration versioning (ADR-0022) merged into develop, so its V21/V22/V23 tenant migrations collided with develop's numeric scheme after rebasing. Renamed to V using the current time, per docs/MIGRATIONS.md. --- ...nnections.sql => V20260708055818__integration_connections.sql} | 0 ...rgets.sql => V20260708055819__project_integration_targets.sql} | 0 ...uth.sql => V20260708055820__integration_connections_oauth.sql} | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename src/main/resources/db/migration/tenant/{V21__integration_connections.sql => V20260708055818__integration_connections.sql} (100%) rename src/main/resources/db/migration/tenant/{V22__project_integration_targets.sql => V20260708055819__project_integration_targets.sql} (100%) rename src/main/resources/db/migration/tenant/{V23__integration_connections_oauth.sql => V20260708055820__integration_connections_oauth.sql} (100%) diff --git a/src/main/resources/db/migration/tenant/V21__integration_connections.sql b/src/main/resources/db/migration/tenant/V20260708055818__integration_connections.sql similarity index 100% rename from src/main/resources/db/migration/tenant/V21__integration_connections.sql rename to src/main/resources/db/migration/tenant/V20260708055818__integration_connections.sql diff --git a/src/main/resources/db/migration/tenant/V22__project_integration_targets.sql b/src/main/resources/db/migration/tenant/V20260708055819__project_integration_targets.sql similarity index 100% rename from src/main/resources/db/migration/tenant/V22__project_integration_targets.sql rename to src/main/resources/db/migration/tenant/V20260708055819__project_integration_targets.sql diff --git a/src/main/resources/db/migration/tenant/V23__integration_connections_oauth.sql b/src/main/resources/db/migration/tenant/V20260708055820__integration_connections_oauth.sql similarity index 100% rename from src/main/resources/db/migration/tenant/V23__integration_connections_oauth.sql rename to src/main/resources/db/migration/tenant/V20260708055820__integration_connections_oauth.sql From 94796ca745fe3722bf34125c941c4f4d721eb813 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Wed, 8 Jul 2026 01:00:45 -0500 Subject: [PATCH 51/72] docs(gateway): renumber Jira integrations ADR references to 0023 The integrations ADR collided on number 0022 with the already-merged flyway timestamp-migration ADR (also 0022) after rebasing onto develop. Renumbered the integrations ADR to 0023 during the rebase; this updates every "(ADR-0022)" cross-reference left in gateway Javadoc, the changelog, and docs/JIRA_INTEGRATION.md to point at the correct 0023 doc. --- CHANGELOG.md | 8 ++++---- docs/JIRA_INTEGRATION.md | 4 ++-- .../application/command/JiraOAuthCallbackCommand.java | 2 +- .../gateway/application/config/JiraOAuthProperties.java | 2 +- .../handler/JiraOAuthCallbackCommandHandler.java | 2 +- .../gateway/application/port/IntegrationProvider.java | 2 +- .../reqsai/gateway/application/port/JiraOAuthPort.java | 2 +- .../reqsai/gateway/application/port/SecretCipher.java | 2 +- .../application/result/JiraOAuthCallbackResult.java | 2 +- .../application/service/JiraOAuthAuthorizeService.java | 2 +- .../application/service/JiraOAuthPendingTokenCache.java | 2 +- .../application/service/JiraOAuthStateService.java | 2 +- .../application/service/JiraOAuthTokenService.java | 2 +- .../gateway/application/service/ProviderRegistry.java | 2 +- .../gateway/domain/exception/IntegrationsError.java | 2 +- .../kntro/reqsai/gateway/domain/model/CredentialType.java | 2 +- .../gateway/domain/model/IntegrationConnection.java | 2 +- .../gateway/domain/model/IntegrationProviderType.java | 2 +- .../gateway/domain/model/ProjectIntegrationTarget.java | 2 +- .../gateway/infrastructure/crypto/AesGcmCipher.java | 2 +- .../crypto/IntegrationsCryptoConfiguration.java | 2 +- .../exception/IntegrationsInfrastructureError.java | 2 +- .../reqsai/gateway/infrastructure/jira/JiraClient.java | 2 +- .../gateway/infrastructure/jira/JiraOAuthAdapter.java | 2 +- .../gateway/infrastructure/jira/JiraOAuthClient.java | 2 +- .../reqsai/gateway/infrastructure/jira/JiraProvider.java | 2 +- .../persistence/converters/EncryptedStringConverter.java | 2 +- .../OrganizationIntegrationControllerImpl.java | 2 +- src/main/java/com/kntro/reqsai/gateway/package-info.java | 2 +- 29 files changed, 33 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 61d06d35..4d2b0e7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,7 +30,7 @@ _Bounded-context implementation (iam, billing, workspace, discovery, gateway) in ### Added (Integrations / Jira — `feature/integrations-jira`) -- **Jira Cloud integration in the reserved `gateway` bounded context** (ADR-0022) — the feature reuses +- **Jira Cloud integration in the reserved `gateway` bounded context** (ADR-0023) — the feature reuses the `com.kntro.reqsai.gateway` module reserved for external integrations. Extensible provider model (`IntegrationProvider` port + `JiraProvider`) whose credentials live at the **organization** level and whose push target lives at the **project** level. @@ -57,12 +57,12 @@ _Bounded-context implementation (iam, billing, workspace, discovery, gateway) in (`DiscoveryStoryReadPort` returning value-only `StoryView`s) so the `gateway` module can read user stories (title/role/action/benefit/priority/story points + Given/When/Then) to render the Jira issue description as ADF. The story is pushed with the `EXPORTED`-style export flow. - - Migration `V21__integration_connections.sql` (tenant schema): `integration_connections` + - Migration `V20260708055818__integration_connections.sql` (tenant schema): `integration_connections` (org-scoped, one active per org+provider) and `project_integration_targets` (project-scoped, one per project). New error codes: `INTEGRATION_CONNECTION_NOT_FOUND`, `INTEGRATION_ALREADY_CONNECTED`, `INTEGRATION_TARGET_NOT_CONFIGURED`, `JIRA_PROJECT_NOT_FOUND`, `JIRA_AUTH_FAILED`, `JIRA_UNREACHABLE`, `JIRA_PUSH_FAILED`, `INTEGRATION_ENCRYPTION_ERROR`. - - **Jira OAuth 2.0 (3LO) as a second credential type** (ADR-0022) — added alongside the API-token + - **Jira OAuth 2.0 (3LO) as a second credential type** (ADR-0023) — added alongside the API-token flow, which is unchanged. A `credentialType` (`API_TOKEN` | `OAUTH2`) selects the auth: OAuth uses bearer auth against `https://api.atlassian.com/ex/jira/{cloudId}/rest/api/3`, API tokens keep basic auth against `https://{site}/rest/api/3`. **`IntegrationConnectionResponse` now carries @@ -85,7 +85,7 @@ _Bounded-context implementation (iam, billing, workspace, discovery, gateway) in `client-secret`, `redirect-uri` (from `JIRA_OAUTH_CLIENT_ID` / `JIRA_OAUTH_CLIENT_SECRET` / `JIRA_OAUTH_CALLBACK_URL`) and a dedicated `state-secret` (`JIRA_OAUTH_STATE_SECRET`; generate with `scripts/generate-oauth-state-secret.sh`). - - Migration `V23__integration_connections_oauth.sql` (tenant, additive): adds `credential_type` + - Migration `V20260708055820__integration_connections_oauth.sql` (tenant, additive): adds `credential_type` (default `API_TOKEN`), `cloud_id`, `oauth_refresh_ciphertext`, `oauth_access_ciphertext`, `oauth_access_expires_at`, and relaxes `email` + `secret_ciphertext` to nullable. New error codes: `JIRA_OAUTH_NOT_CONFIGURED` (501), `JIRA_OAUTH_STATE_INVALID` (400), diff --git a/docs/JIRA_INTEGRATION.md b/docs/JIRA_INTEGRATION.md index 732a4bbb..aff54c90 100644 --- a/docs/JIRA_INTEGRATION.md +++ b/docs/JIRA_INTEGRATION.md @@ -2,7 +2,7 @@ Reqs-AI can push **user stories to Jira Cloud** as issues. The integration lives in the **`gateway`** bounded context and is designed to be provider-extensible (Jira is the first provider). See -[ADR-0022](adr/0022-third-party-integrations-jira.md) for the design rationale. +[ADR-0023](adr/0023-third-party-integrations-jira.md) for the design rationale. - **Connection is organization-level** — credentials are stored once per org, encrypted at rest. - **Push target is project-level** — each project picks which Jira project + issue type its stories go to. @@ -149,7 +149,7 @@ Actions are RBAC-gated by new permissions: `INTEGRATION_READ`, `INTEGRATION_WRIT ## Reference -- **Design:** [ADR-0022](adr/0022-third-party-integrations-jira.md) +- **Design:** [ADR-0023](adr/0023-third-party-integrations-jira.md) - **Module:** `com.kntro.reqsai.gateway` - **Migrations (tenant):** `V21` connections, `V22` targets, `V23` OAuth columns - **Config keys:** `reqsai.integrations.encryption-key`, `reqsai.integrations.jira.oauth.{client-id,client-secret,redirect-uri,state-secret}` diff --git a/src/main/java/com/kntro/reqsai/gateway/application/command/JiraOAuthCallbackCommand.java b/src/main/java/com/kntro/reqsai/gateway/application/command/JiraOAuthCallbackCommand.java index 90400f73..b75aeee2 100644 --- a/src/main/java/com/kntro/reqsai/gateway/application/command/JiraOAuthCallbackCommand.java +++ b/src/main/java/com/kntro/reqsai/gateway/application/command/JiraOAuthCallbackCommand.java @@ -5,7 +5,7 @@ import java.util.UUID; /** - * Completes the Jira OAuth 2.0 (3LO) flow at the organization level (ADR-0022): validates {@code state}, + * Completes the Jira OAuth 2.0 (3LO) flow at the organization level (ADR-0023): validates {@code state}, * exchanges {@code code}, discovers accessible sites and — if a site is chosen ({@code cloudId} given or * exactly one available) — persists an OAUTH2 connection. When multiple sites exist and {@code cloudId} * is null the handler returns the site list WITHOUT saving. diff --git a/src/main/java/com/kntro/reqsai/gateway/application/config/JiraOAuthProperties.java b/src/main/java/com/kntro/reqsai/gateway/application/config/JiraOAuthProperties.java index cd0f9c63..bdc8ed7f 100644 --- a/src/main/java/com/kntro/reqsai/gateway/application/config/JiraOAuthProperties.java +++ b/src/main/java/com/kntro/reqsai/gateway/application/config/JiraOAuthProperties.java @@ -4,7 +4,7 @@ import org.jspecify.annotations.Nullable; /** - * Jira OAuth 2.0 (3LO) app configuration bound from {@code reqsai.integrations.jira.oauth.*} (ADR-0022). + * Jira OAuth 2.0 (3LO) app configuration bound from {@code reqsai.integrations.jira.oauth.*} (ADR-0023). *

    * All fields are OPTIONAL: when {@link #clientId}, {@link #clientSecret} or {@link #redirectUri} is * blank the feature is considered not configured ({@link #configured()} is false) and the OAuth diff --git a/src/main/java/com/kntro/reqsai/gateway/application/handler/JiraOAuthCallbackCommandHandler.java b/src/main/java/com/kntro/reqsai/gateway/application/handler/JiraOAuthCallbackCommandHandler.java index a176c260..de87ea7b 100644 --- a/src/main/java/com/kntro/reqsai/gateway/application/handler/JiraOAuthCallbackCommandHandler.java +++ b/src/main/java/com/kntro/reqsai/gateway/application/handler/JiraOAuthCallbackCommandHandler.java @@ -22,7 +22,7 @@ import java.util.List; /** - * Completes the Jira OAuth 2.0 (3LO) org-level flow (ADR-0022): + * Completes the Jira OAuth 2.0 (3LO) org-level flow (ADR-0023): *

      *
    1. reject if OAuth is not configured ({@code JIRA_OAUTH_NOT_CONFIGURED});
    2. *
    3. validate the signed {@code state} against this org+user ({@code JIRA_OAUTH_STATE_INVALID});
    4. diff --git a/src/main/java/com/kntro/reqsai/gateway/application/port/IntegrationProvider.java b/src/main/java/com/kntro/reqsai/gateway/application/port/IntegrationProvider.java index ab2c9b0d..d2e71c20 100644 --- a/src/main/java/com/kntro/reqsai/gateway/application/port/IntegrationProvider.java +++ b/src/main/java/com/kntro/reqsai/gateway/application/port/IntegrationProvider.java @@ -8,7 +8,7 @@ import java.util.List; /** - * Provider seam (ADR-0022): the capability of talking to a third-party tracker. Jira is the first + * Provider seam (ADR-0023): the capability of talking to a third-party tracker. Jira is the first * implementation ({@code JiraProvider}); adding another provider means adding an implementation keyed * by its {@link IntegrationProviderType}, with no change to the handlers or endpoints. * diff --git a/src/main/java/com/kntro/reqsai/gateway/application/port/JiraOAuthPort.java b/src/main/java/com/kntro/reqsai/gateway/application/port/JiraOAuthPort.java index 1db10596..a1b68c3e 100644 --- a/src/main/java/com/kntro/reqsai/gateway/application/port/JiraOAuthPort.java +++ b/src/main/java/com/kntro/reqsai/gateway/application/port/JiraOAuthPort.java @@ -5,7 +5,7 @@ import java.util.List; /** - * Application seam for the Atlassian OAuth 2.0 (3LO) endpoints (ADR-0022): authorization-code exchange, + * Application seam for the Atlassian OAuth 2.0 (3LO) endpoints (ADR-0023): authorization-code exchange, * refresh-token rotation, and accessible-resources discovery. The concrete HTTP lives in an * infrastructure adapter over {@code JiraOAuthClient}; application code programs against this port so it * never touches {@code infrastructure}. Tokens are opaque strings and are never logged by callers. diff --git a/src/main/java/com/kntro/reqsai/gateway/application/port/SecretCipher.java b/src/main/java/com/kntro/reqsai/gateway/application/port/SecretCipher.java index 808d6c36..65d8803f 100644 --- a/src/main/java/com/kntro/reqsai/gateway/application/port/SecretCipher.java +++ b/src/main/java/com/kntro/reqsai/gateway/application/port/SecretCipher.java @@ -1,7 +1,7 @@ package com.kntro.reqsai.gateway.application.port; /** - * Port for symmetric encryption of integration secrets at rest (ADR-0022). + * Port for symmetric encryption of integration secrets at rest (ADR-0023). *

      * Abstracts the cipher used to protect sensitive credentials (e.g. the Jira API token) before they * are persisted, and to recover them on load. Callers program against this port; the concrete diff --git a/src/main/java/com/kntro/reqsai/gateway/application/result/JiraOAuthCallbackResult.java b/src/main/java/com/kntro/reqsai/gateway/application/result/JiraOAuthCallbackResult.java index 88d75cba..74038b72 100644 --- a/src/main/java/com/kntro/reqsai/gateway/application/result/JiraOAuthCallbackResult.java +++ b/src/main/java/com/kntro/reqsai/gateway/application/result/JiraOAuthCallbackResult.java @@ -7,7 +7,7 @@ import java.util.List; /** - * Outcome of the Jira OAuth callback (ADR-0022): either a saved {@link #connection} (a site was chosen or + * Outcome of the Jira OAuth callback (ADR-0023): either a saved {@link #connection} (a site was chosen or * auto-selected), or a non-empty list of {@link #sites} to choose from (multiple sites, no {@code cloudId} * yet) — in which case nothing was persisted and the frontend re-POSTs with a chosen {@code cloudId}. * Exactly one of the two is non-null. diff --git a/src/main/java/com/kntro/reqsai/gateway/application/service/JiraOAuthAuthorizeService.java b/src/main/java/com/kntro/reqsai/gateway/application/service/JiraOAuthAuthorizeService.java index 4aa40ef7..c4d1e6da 100644 --- a/src/main/java/com/kntro/reqsai/gateway/application/service/JiraOAuthAuthorizeService.java +++ b/src/main/java/com/kntro/reqsai/gateway/application/service/JiraOAuthAuthorizeService.java @@ -8,7 +8,7 @@ import java.util.UUID; /** - * Builds the Atlassian authorize URL for the OAuth 2.0 (3LO) flow (ADR-0022): + * Builds the Atlassian authorize URL for the OAuth 2.0 (3LO) flow (ADR-0023): * {@code https://auth.atlassian.com/authorize?audience=api.atlassian.com&client_id=...&scope=...& * redirect_uri=...&state=...&response_type=code&prompt=consent}. The {@code state} is a stateless signed * token from {@link JiraOAuthStateService}. When OAuth is not configured it raises diff --git a/src/main/java/com/kntro/reqsai/gateway/application/service/JiraOAuthPendingTokenCache.java b/src/main/java/com/kntro/reqsai/gateway/application/service/JiraOAuthPendingTokenCache.java index fdd1a994..81694d81 100644 --- a/src/main/java/com/kntro/reqsai/gateway/application/service/JiraOAuthPendingTokenCache.java +++ b/src/main/java/com/kntro/reqsai/gateway/application/service/JiraOAuthPendingTokenCache.java @@ -13,7 +13,7 @@ /** * Short-lived in-memory cache of a completed OAuth code exchange, keyed by the signed {@code state} - * (ADR-0022). + * (ADR-0023). *

      * Atlassian authorization codes are SINGLE-USE: the multi-site callback exchanges the code once (to call * accessible-resources) and, when the user must still pick a site, cannot exchange it again on the second diff --git a/src/main/java/com/kntro/reqsai/gateway/application/service/JiraOAuthStateService.java b/src/main/java/com/kntro/reqsai/gateway/application/service/JiraOAuthStateService.java index 59e2819c..a444a21a 100644 --- a/src/main/java/com/kntro/reqsai/gateway/application/service/JiraOAuthStateService.java +++ b/src/main/java/com/kntro/reqsai/gateway/application/service/JiraOAuthStateService.java @@ -15,7 +15,7 @@ import java.util.UUID; /** - * Signs and verifies the STATELESS OAuth {@code state} token (ADR-0022). The token binds the CSRF state + * Signs and verifies the STATELESS OAuth {@code state} token (ADR-0023). The token binds the CSRF state * to the initiating {@code orgId} + {@code userId} with a short expiry and a random nonce, so nothing has * to be stored server-side and it survives the browser redirect. Format: *

      {@code base64url(orgId|userId|expiryEpochSeconds|nonce) + "." + base64url(HMAC-SHA256(payload))}
      diff --git a/src/main/java/com/kntro/reqsai/gateway/application/service/JiraOAuthTokenService.java b/src/main/java/com/kntro/reqsai/gateway/application/service/JiraOAuthTokenService.java index 1fe25f04..c77d587c 100644 --- a/src/main/java/com/kntro/reqsai/gateway/application/service/JiraOAuthTokenService.java +++ b/src/main/java/com/kntro/reqsai/gateway/application/service/JiraOAuthTokenService.java @@ -15,7 +15,7 @@ /** * Ensures an OAuth 2.0 (3LO) {@link IntegrationConnection} has a usable, non-expired access token before a - * provider call (ADR-0022). If the cached access token is missing, expired, or within {@link #SKEW} of + * provider call (ADR-0023). If the cached access token is missing, expired, or within {@link #SKEW} of * expiring, it refreshes via {@link JiraOAuthPort}, persists the rotated tokens (encrypted) + new expiry, * and returns the fresh access token. A refresh failure surfaces as {@code JIRA_AUTH_FAILED}. Tokens are * never logged. diff --git a/src/main/java/com/kntro/reqsai/gateway/application/service/ProviderRegistry.java b/src/main/java/com/kntro/reqsai/gateway/application/service/ProviderRegistry.java index efd6cb5a..39bb6fb9 100644 --- a/src/main/java/com/kntro/reqsai/gateway/application/service/ProviderRegistry.java +++ b/src/main/java/com/kntro/reqsai/gateway/application/service/ProviderRegistry.java @@ -9,7 +9,7 @@ import java.util.Map; /** - * Resolves the {@link IntegrationProvider} for a given {@link IntegrationProviderType} (ADR-0022 provider + * Resolves the {@link IntegrationProvider} for a given {@link IntegrationProviderType} (ADR-0023 provider * seam). Indexes every provider bean by its {@code type()}; adding a provider is purely additive. */ @Component diff --git a/src/main/java/com/kntro/reqsai/gateway/domain/exception/IntegrationsError.java b/src/main/java/com/kntro/reqsai/gateway/domain/exception/IntegrationsError.java index 9ae352a8..42c19691 100644 --- a/src/main/java/com/kntro/reqsai/gateway/domain/exception/IntegrationsError.java +++ b/src/main/java/com/kntro/reqsai/gateway/domain/exception/IntegrationsError.java @@ -4,7 +4,7 @@ import org.springframework.http.HttpStatus; /** - * Domain (business-rule) error codes owned by the Integrations bounded context (ADR-0022). Mapped to + * Domain (business-rule) error codes owned by the Integrations bounded context (ADR-0023). Mapped to * RFC 9457 {@code ProblemDetail} by the shared {@code GlobalExceptionHandler}. External-service * failures live in {@link com.kntro.reqsai.gateway.infrastructure.exception.IntegrationsInfrastructureError}. */ diff --git a/src/main/java/com/kntro/reqsai/gateway/domain/model/CredentialType.java b/src/main/java/com/kntro/reqsai/gateway/domain/model/CredentialType.java index d9fecaeb..57520e74 100644 --- a/src/main/java/com/kntro/reqsai/gateway/domain/model/CredentialType.java +++ b/src/main/java/com/kntro/reqsai/gateway/domain/model/CredentialType.java @@ -1,7 +1,7 @@ package com.kntro.reqsai.gateway.domain.model; /** - * How an {@link IntegrationConnection} authenticates against its provider (ADR-0022). + * How an {@link IntegrationConnection} authenticates against its provider (ADR-0023). *
        *
      • {@code API_TOKEN} — Jira basic auth: {@code Authorization: Basic base64(email:token)} against * {@code https://{site}/rest/api/3}. The {@code email} + encrypted {@code secret_ciphertext} are diff --git a/src/main/java/com/kntro/reqsai/gateway/domain/model/IntegrationConnection.java b/src/main/java/com/kntro/reqsai/gateway/domain/model/IntegrationConnection.java index 2ba23ce1..bac1653d 100644 --- a/src/main/java/com/kntro/reqsai/gateway/domain/model/IntegrationConnection.java +++ b/src/main/java/com/kntro/reqsai/gateway/domain/model/IntegrationConnection.java @@ -16,7 +16,7 @@ import java.util.UUID; /** - * Organization-scoped third-party integration connection (ADR-0022). Holds the provider, the Jira site + * Organization-scoped third-party integration connection (ADR-0023). Holds the provider, the Jira site * URL, and one of two credential shapes selected by {@link #credentialType}: *
          *
        • {@link CredentialType#API_TOKEN} — account {@code email} + the API token diff --git a/src/main/java/com/kntro/reqsai/gateway/domain/model/IntegrationProviderType.java b/src/main/java/com/kntro/reqsai/gateway/domain/model/IntegrationProviderType.java index 462056ef..88be028c 100644 --- a/src/main/java/com/kntro/reqsai/gateway/domain/model/IntegrationProviderType.java +++ b/src/main/java/com/kntro/reqsai/gateway/domain/model/IntegrationProviderType.java @@ -2,7 +2,7 @@ /** * Supported third-party integration providers. Only {@code JIRA} exists today; the value is stored on - * {@code IntegrationConnection} and drives provider-adapter selection (ADR-0022), so adding a provider + * {@code IntegrationConnection} and drives provider-adapter selection (ADR-0023), so adding a provider * (e.g. Azure DevOps) is additive. */ public enum IntegrationProviderType { diff --git a/src/main/java/com/kntro/reqsai/gateway/domain/model/ProjectIntegrationTarget.java b/src/main/java/com/kntro/reqsai/gateway/domain/model/ProjectIntegrationTarget.java index 841f13cf..a54efe3c 100644 --- a/src/main/java/com/kntro/reqsai/gateway/domain/model/ProjectIntegrationTarget.java +++ b/src/main/java/com/kntro/reqsai/gateway/domain/model/ProjectIntegrationTarget.java @@ -10,7 +10,7 @@ import java.util.UUID; /** - * Project-scoped push target (ADR-0022): the Jira project key + issue type a Reqs-AI project's stories + * Project-scoped push target (ADR-0023): the Jira project key + issue type a Reqs-AI project's stories * are pushed to, referencing the org-level {@link IntegrationConnection}. Exactly one per project (the * {@code PUT .../target} endpoint upserts this single row). */ diff --git a/src/main/java/com/kntro/reqsai/gateway/infrastructure/crypto/AesGcmCipher.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/crypto/AesGcmCipher.java index 3e982ab2..7a715af2 100644 --- a/src/main/java/com/kntro/reqsai/gateway/infrastructure/crypto/AesGcmCipher.java +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/crypto/AesGcmCipher.java @@ -11,7 +11,7 @@ import java.util.Base64; /** - * AES-256-GCM symmetric encryption for integration secrets at rest (ADR-0022). + * AES-256-GCM symmetric encryption for integration secrets at rest (ADR-0023). *

          * Each value gets a fresh random 12-byte IV, prepended to the ciphertext+tag so decryption is * self-describing: the stored bytes are {@code IV(12) || ciphertext||tag}. The key is a base64-encoded diff --git a/src/main/java/com/kntro/reqsai/gateway/infrastructure/crypto/IntegrationsCryptoConfiguration.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/crypto/IntegrationsCryptoConfiguration.java index 0b4c7167..ce908ea1 100644 --- a/src/main/java/com/kntro/reqsai/gateway/infrastructure/crypto/IntegrationsCryptoConfiguration.java +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/crypto/IntegrationsCryptoConfiguration.java @@ -10,7 +10,7 @@ import org.springframework.context.annotation.Configuration; /** - * Wires the AES-256-GCM cipher used to encrypt integration secrets at rest (ADR-0022) and injects it + * Wires the AES-256-GCM cipher used to encrypt integration secrets at rest (ADR-0023) and injects it * into the Hibernate-instantiated {@link EncryptedStringConverter} via its static holder. *

          * The key comes from {@code INTEGRATIONS_ENCRYPTION_KEY} (base64, 32 bytes). It is required for the diff --git a/src/main/java/com/kntro/reqsai/gateway/infrastructure/exception/IntegrationsInfrastructureError.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/exception/IntegrationsInfrastructureError.java index 25e1506f..e46192e8 100644 --- a/src/main/java/com/kntro/reqsai/gateway/infrastructure/exception/IntegrationsInfrastructureError.java +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/exception/IntegrationsInfrastructureError.java @@ -4,7 +4,7 @@ import org.springframework.http.HttpStatus; /** - * Error codes for external-service and crypto failures in the Integrations bounded context (ADR-0022). + * Error codes for external-service and crypto failures in the Integrations bounded context (ADR-0023). * These are infrastructure concerns (Jira reachability/auth, encryption) and must NOT live in * {@link com.kntro.reqsai.gateway.domain.exception.IntegrationsError}. */ diff --git a/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraClient.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraClient.java index 80188c83..b2d20716 100644 --- a/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraClient.java +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraClient.java @@ -20,7 +20,7 @@ import java.util.Map; /** - * Outbound Jira Cloud REST v3 client (ADR-0022), dual-mode across the two credential types: + * Outbound Jira Cloud REST v3 client (ADR-0023), dual-mode across the two credential types: *

            *
          • API_TOKEN — base {@code https://{site}/rest/api/3} with basic auth * ({@code Authorization: Basic base64(email:token)}).
          • diff --git a/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraOAuthAdapter.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraOAuthAdapter.java index d51ba2e1..a9cfa28c 100644 --- a/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraOAuthAdapter.java +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraOAuthAdapter.java @@ -7,7 +7,7 @@ import java.util.List; /** - * Adapts the {@link JiraOAuthPort} application port to the {@link JiraOAuthClient} HTTP client (ADR-0022), + * Adapts the {@link JiraOAuthPort} application port to the {@link JiraOAuthClient} HTTP client (ADR-0023), * translating the client's Jackson records into the port's value records. Keeps application code off * infrastructure. */ diff --git a/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraOAuthClient.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraOAuthClient.java index 23cf9a86..e1c1e559 100644 --- a/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraOAuthClient.java +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraOAuthClient.java @@ -14,7 +14,7 @@ import java.util.Map; /** - * Outbound Atlassian OAuth 2.0 (3LO) client (ADR-0022): authorization-code exchange, refresh-token + * Outbound Atlassian OAuth 2.0 (3LO) client (ADR-0023): authorization-code exchange, refresh-token * rotation, and accessible-resources discovery. Mirrors the {@link JiraClient} RestClient style (per-call * client, typed records, status → infrastructure exception). *
              diff --git a/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraProvider.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraProvider.java index da159865..d2b5b08c 100644 --- a/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraProvider.java +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraProvider.java @@ -12,7 +12,7 @@ import java.util.Map; /** - * Jira Cloud implementation of {@link IntegrationProvider} (ADR-0022). Translates provider-neutral calls + * Jira Cloud implementation of {@link IntegrationProvider} (ADR-0023). Translates provider-neutral calls * into {@link JiraClient} REST calls and renders the story description as ADF via {@link JiraAdfBuilder}. *

              * Dual-mode: {@link #contextFor(ProviderCredentials)} picks the base URL + {@code Authorization} header diff --git a/src/main/java/com/kntro/reqsai/gateway/infrastructure/persistence/converters/EncryptedStringConverter.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/persistence/converters/EncryptedStringConverter.java index 42d8f244..43c3c390 100644 --- a/src/main/java/com/kntro/reqsai/gateway/infrastructure/persistence/converters/EncryptedStringConverter.java +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/persistence/converters/EncryptedStringConverter.java @@ -10,7 +10,7 @@ /** * JPA converter that encrypts a {@code String} attribute (the Jira API token) to a {@code byte[]} - * ({@code secret_ciphertext} BYTEA) with AES-256-GCM and decrypts it on load (ADR-0022). + * ({@code secret_ciphertext} BYTEA) with AES-256-GCM and decrypts it on load (ADR-0023). *

              * JPA converters are instantiated by Hibernate, not Spring, so the {@link SecretCipher} is supplied * through a static holder set once at startup by {@code IntegrationsCryptoConfiguration}. A missing diff --git a/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/controllers/OrganizationIntegrationControllerImpl.java b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/controllers/OrganizationIntegrationControllerImpl.java index e19ee76c..678593f1 100644 --- a/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/controllers/OrganizationIntegrationControllerImpl.java +++ b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/controllers/OrganizationIntegrationControllerImpl.java @@ -41,7 +41,7 @@ /** * Organization-level integration endpoints. Administering an org-wide credential is an org-admin action, - * so every method is gated by {@code @authz.orgOwnerOrAdmin} (ADR-0022). + * so every method is gated by {@code @authz.orgOwnerOrAdmin} (ADR-0023). */ @RestController @RequiredArgsConstructor diff --git a/src/main/java/com/kntro/reqsai/gateway/package-info.java b/src/main/java/com/kntro/reqsai/gateway/package-info.java index d5d150c9..192252ff 100644 --- a/src/main/java/com/kntro/reqsai/gateway/package-info.java +++ b/src/main/java/com/kntro/reqsai/gateway/package-info.java @@ -1,5 +1,5 @@ /** - * Gateway — external integrations bounded context (ADR-0022). + * Gateway — external integrations bounded context (ADR-0023). *

              * Third-party tracker connections and story push, whose first provider is Jira Cloud. Extensible * provider model: credentials live at the organization level From 5ccf1b5e37483723b96bfefdfbd8690b8ed06aeb Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Thu, 9 Jul 2026 21:16:49 -0500 Subject: [PATCH 52/72] fix(discovery): skip the llm in the import duplicate pre-check checkDuplicate ran the full LLM transform per remote issue, making the import preview take minutes on real projects; the similarity badge now uses the deterministic mapping (the gate re-runs against the final story at import time). --- .../application/service/DiscoveryStoryWritePortImpl.java | 5 ++++- .../application/service/DiscoveryStoryWritePortImplTest.java | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/kntro/reqsai/discovery/application/service/DiscoveryStoryWritePortImpl.java b/src/main/java/com/kntro/reqsai/discovery/application/service/DiscoveryStoryWritePortImpl.java index 5ecb11e8..482dee04 100644 --- a/src/main/java/com/kntro/reqsai/discovery/application/service/DiscoveryStoryWritePortImpl.java +++ b/src/main/java/com/kntro/reqsai/discovery/application/service/DiscoveryStoryWritePortImpl.java @@ -97,7 +97,10 @@ public StoryDuplicateCheck checkDuplicate(ExternalIssueInput input) { if (!embeddingPort.isAvailable()) { return StoryDuplicateCheck.notDuplicate(); } - return resolveDuplicate(input.projectId(), transform(input)); + // Deliberately uses the deterministic mapping (NOT the LLM): preview calls this once per remote + // issue, and an LLM generation per issue makes previews take minutes. The similarity gate re-runs + // at import time against the final (possibly LLM-transformed) story, so the badge stays a preview. + return resolveDuplicate(input.projectId(), fallback(input)); } /** diff --git a/src/test/java/com/kntro/reqsai/discovery/application/service/DiscoveryStoryWritePortImplTest.java b/src/test/java/com/kntro/reqsai/discovery/application/service/DiscoveryStoryWritePortImplTest.java index 8b3dbf0b..2c9fd76a 100644 --- a/src/test/java/com/kntro/reqsai/discovery/application/service/DiscoveryStoryWritePortImplTest.java +++ b/src/test/java/com/kntro/reqsai/discovery/application/service/DiscoveryStoryWritePortImplTest.java @@ -135,7 +135,7 @@ void duplicate_is_reported_not_created() { @DisplayName("checkDuplicate: flags a near-duplicate without creating anything") void check_duplicate_flags_without_creating() { UUID existing = UUID.randomUUID(); - when(generationPort.isAvailable()).thenReturn(false); + // checkDuplicate deliberately never consults the LLM (deterministic mapping only) — no generation stub. when(embeddingPort.isAvailable()).thenReturn(true); when(embeddingPort.embed(any())).thenReturn(new float[EmbeddingPort.DIMENSIONS]); when(stories.findMostSimilar(any(), any())) From 9b9611f9fbacfd8399d99279bdf68f46972f1378 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Thu, 9 Jul 2026 21:18:00 -0500 Subject: [PATCH 53/72] fix(gateway): stop double-encoding the jira search jql uri searchIssues pre-encoded the JQL and then passed the string to RestClient.uri, which treats it as a template and re-encodes it (%22 -> %2522); Jira rejected the garbled JQL with a 400 surfaced as JIRA_UNREACHABLE. Pass a java.net.URI so the pre-encoded query goes out untouched. --- .../kntro/reqsai/gateway/infrastructure/jira/JiraClient.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraClient.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraClient.java index b2d20716..7fa41e05 100644 --- a/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraClient.java +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraClient.java @@ -193,8 +193,10 @@ public IssueSearchPage searchIssues(JiraApiContext ctx, String jql, int maxResul if (nextPageToken != null && !nextPageToken.isBlank()) { uri.append("&nextPageToken=").append(enc(nextPageToken)); } + // The JQL is already URL-encoded via enc(); pass a java.net.URI so RestClient does NOT treat the + // string as a template and re-encode it (double-encoding turned %22 into %2522 → Jira 400). IssueSearchResponse res = exchange(() -> restClient.get() - .uri(uri.toString()) + .uri(java.net.URI.create(uri.toString())) .header("Authorization", ctx.authHeader()) .accept(MediaType.APPLICATION_JSON) .retrieve() From edb62af23a2999846e04a9f349f0153e16c75138 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Thu, 9 Jul 2026 21:18:00 -0500 Subject: [PATCH 54/72] feat(gateway): fill required jira custom fields on issue create Projects can mark custom fields required on the create screen (e.g. a mandatory 'Criterios de aceptacion'), which failed every push with a 400. createIssue now reads the create-screen meta for the project + issue type and fills required defaultless fields generically: plain text for string fields, an ADF doc for rich-text fields (type doc, or string with a textarea/paragraph custom kind, which the v3 API only accepts as ADF). The value is the story's acceptance criteria rendered as Given/When/Then lines. --- .../infrastructure/jira/JiraClient.java | 90 +++++++++++++++++-- .../infrastructure/jira/JiraProvider.java | 18 +++- .../reqsai/gateway/StubJiraOAuthConfig.java | 3 +- .../infrastructure/jira/JiraClientTest.java | 17 +++- 4 files changed, 116 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraClient.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraClient.java index 7fa41e05..70aaffda 100644 --- a/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraClient.java +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraClient.java @@ -149,19 +149,46 @@ public String resolveIssueTypeId(JiraApiContext ctx, String projectKey, String i + "' (available: " + types.stream().map(JiraIssueType::name).toList() + ")")); } + /** + * GET {@code /issue/createmeta/{projectKey}/issuetypes/{issueTypeId}} → the create-screen fields for + * that project + issue type (required flag, default flag and schema type). Used to satisfy + * project-specific REQUIRED custom fields (e.g. a mandatory "Criterios de aceptación" text field) that + * would otherwise fail the create with a 400. + */ + public List listCreateFields(JiraApiContext ctx, String projectKey, String issueTypeId) { + CreateMetaFields meta = exchange(() -> restClient.get() + .uri(ctx.apiBase() + "/issue/createmeta/" + enc(projectKey) + "/issuetypes/" + + enc(issueTypeId) + "?maxResults=200") + .header("Authorization", ctx.authHeader()) + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .onStatus(HttpStatusCode::isError, (req, res) -> { throw mapError(res, false); }) + .body(CreateMetaFields.class), "listCreateFields"); + return meta == null || meta.fields() == null ? List.of() : meta.fields(); + } + + /** The base create fields Reqs-AI always sends; anything else required must be filled generically. */ + private static final java.util.Set BASE_FIELDS = + java.util.Set.of("project", "issuetype", "summary", "description", "reporter"); + /** * POST /issue → the created issue's id/key/self. Sends {@code issuetype:{id:…}} (resolved for the - * project) so team-managed and localized projects accept the create. Reads Jira's error body on failure - * and surfaces its {@code errorMessages}/field {@code errors} (token-free) for diagnosability. + * project) so team-managed and localized projects accept the create. Any OTHER field the project marks + * required without a default (custom fields like "Criterios de aceptación") is filled generically from + * {@code requiredFieldFallbackText}: plain text for {@code string} fields, an ADF doc for {@code doc} + * fields (unfillable types are left for Jira to report). Reads Jira's error body on failure and + * surfaces its {@code errorMessages}/field {@code errors} (token-free) for diagnosability. */ public CreatedIssue createIssue(JiraApiContext ctx, String projectKey, String issueTypeName, - String summary, Map descriptionAdf) { + String summary, Map descriptionAdf, + String requiredFieldFallbackText) { String issueTypeId = resolveIssueTypeId(ctx, projectKey, issueTypeName); - Map fields = Map.of( - "project", Map.of("key", projectKey), - "issuetype", Map.of("id", issueTypeId), - "summary", summary, - "description", descriptionAdf); + Map fields = new LinkedHashMap<>(); + fields.put("project", Map.of("key", projectKey)); + fields.put("issuetype", Map.of("id", issueTypeId)); + fields.put("summary", summary); + fields.put("description", descriptionAdf); + fillRequiredCustomFields(ctx, projectKey, issueTypeId, fields, requiredFieldFallbackText); CreatedIssue created = exchange(() -> restClient.post() .uri(ctx.apiBase() + "/issue") .header("Authorization", ctx.authHeader()) @@ -226,6 +253,40 @@ public String browseUrl(String browseBase, String issueKey) { return browseBase + "/browse/" + issueKey; } + /** + * Fills every create-screen field the project marks {@code required} without a default and that the + * base payload does not already cover, so project-specific mandatory custom fields (e.g. a required + * "Criterios de aceptación") don't 400 the create. {@code string} fields get {@code fallbackText}; + * rich-text {@code doc} fields get a single-paragraph ADF doc. Other types (options, numbers, users…) + * cannot be guessed generically and are left unset — Jira's diagnosable 400 then names them. + */ + private void fillRequiredCustomFields(JiraApiContext ctx, String projectKey, String issueTypeId, + Map fields, String fallbackText) { + String text = fallbackText == null || fallbackText.isBlank() ? "See description." : fallbackText; + for (CreateField field : listCreateFields(ctx, projectKey, issueTypeId)) { + String id = field.fieldId(); + if (id == null || fields.containsKey(id) || BASE_FIELDS.contains(id) + || !Boolean.TRUE.equals(field.required()) + || Boolean.TRUE.equals(field.hasDefaultValue())) { + continue; + } + String type = field.schema() == null ? null : field.schema().type(); + String custom = field.schema() == null ? null : field.schema().custom(); + // Rich-text fields need an ADF doc even when the schema type says "string": the v3 API + // requires ADF for textarea/paragraph custom fields (team-managed projects report them as + // string+custom:…textarea, and a plain string is rejected as "not valid ADF"). + boolean richText = "doc".equals(type) + || (custom != null && (custom.contains("textarea") || custom.contains("paragraph"))); + if (richText) { + fields.put(id, Map.of("type", "doc", "version", 1, "content", + List.of(Map.of("type", "paragraph", "content", + List.of(Map.of("type", "text", "text", text)))))); + } else if ("string".equals(type)) { + fields.put(id, text); + } + } + } + // Helpers private static String enc(String raw) { @@ -309,6 +370,19 @@ public record JiraIssueType(String id, String name) {} @JsonIgnoreProperties(ignoreUnknown = true) private record CreateMetaIssueTypes(List issueTypes) {} + /** One create-screen field from {@code createmeta/{project}/issuetypes/{typeId}}. */ + public record CreateField(String fieldId, String name, Boolean required, Boolean hasDefaultValue, + FieldSchema schema) {} + + /** + * The schema of a create-screen field. {@code type} is the value type ({@code string}, {@code doc}, + * {@code array}, …); {@code custom} names the custom-field kind (e.g. {@code …:textarea}) — needed + * because rich-text fields report {@code type=string} but the v3 API requires ADF values for them. + */ + public record FieldSchema(String type, String custom) {} + + private record CreateMetaFields(List fields) {} + @JsonIgnoreProperties(ignoreUnknown = true) public record CreatedIssue(String id, String key, String self) {} diff --git a/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraProvider.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraProvider.java index d2b5b08c..3f6d5d8b 100644 --- a/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraProvider.java +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraProvider.java @@ -54,10 +54,26 @@ public List listIssueTypes(ProviderCredentials c, String projec public PushedIssue pushStory(ProviderCredentials c, String projectKey, String issueTypeName, StoryView story) { JiraApiContext ctx = contextFor(c); Map description = JiraAdfBuilder.buildDescription(story); - JiraClient.CreatedIssue created = jira.createIssue(ctx, projectKey, issueTypeName, story.title(), description); + JiraClient.CreatedIssue created = jira.createIssue( + ctx, projectKey, issueTypeName, story.title(), description, acceptanceCriteriaText(story)); return new PushedIssue(created.key(), jira.browseUrl(ctx.browseBase(), created.key())); } + /** + * Renders the story's acceptance criteria as plain {@code Given … When … Then …} lines — the generic + * value used to satisfy project-specific REQUIRED custom fields (e.g. a mandatory + * "Criterios de aceptación"). Empty when the story has none (the client then sends a neutral note). + */ + private static String acceptanceCriteriaText(StoryView story) { + if (story.acceptanceCriteria() == null || story.acceptanceCriteria().isEmpty()) { + return ""; + } + return story.acceptanceCriteria().stream() + .map(c -> (c.scenario() != null && !c.scenario().isBlank() ? c.scenario() + ": " : "") + + "Given " + c.given() + ", When " + c.when() + ", Then " + c.then() + ".") + .collect(java.util.stream.Collectors.joining("\n")); + } + @Override public List searchImportableIssues(ProviderCredentials c, String projectKey, String issueTypeName) { JiraApiContext ctx = contextFor(c); diff --git a/src/test/java/com/kntro/reqsai/gateway/StubJiraOAuthConfig.java b/src/test/java/com/kntro/reqsai/gateway/StubJiraOAuthConfig.java index 5f83666e..65d50b14 100644 --- a/src/test/java/com/kntro/reqsai/gateway/StubJiraOAuthConfig.java +++ b/src/test/java/com/kntro/reqsai/gateway/StubJiraOAuthConfig.java @@ -49,7 +49,8 @@ public List listIssueTypes(JiraApiContext ctx, String projectKey) @Override public CreatedIssue createIssue(JiraApiContext ctx, String projectKey, String issueTypeName, - String summary, Map descriptionAdf) { + String summary, Map descriptionAdf, + String requiredFieldFallbackText) { apiBases.add(ctx.apiBase()); String key = projectKey + "-42"; return new CreatedIssue("42", key, ctx.apiBase() + "/issue/" + key); diff --git a/src/test/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraClientTest.java b/src/test/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraClientTest.java index 5b9c090e..80725d01 100644 --- a/src/test/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraClientTest.java +++ b/src/test/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraClientTest.java @@ -70,16 +70,27 @@ void create_sends_issue_type_by_id() { server.expect(requestTo(BASE + "/issue/createmeta/PAY/issuetypes?maxResults=200")) .andRespond(withSuccess("{\"issueTypes\":[{\"id\":\"10001\",\"name\":\"Historia\"}]}", MediaType.APPLICATION_JSON)); + // The create-screen meta declares a REQUIRED rich-text custom field (the real-world + // "Criterios de aceptación" case) — the client must fill it generically or Jira 400s. + server.expect(requestTo(BASE + "/issue/createmeta/PAY/issuetypes/10001?maxResults=200")) + .andRespond(withSuccess("{\"fields\":[{\"fieldId\":\"customfield_10037\"," + + "\"name\":\"Criterios de aceptación\",\"required\":true," + + "\"hasDefaultValue\":false,\"schema\":{\"type\":\"doc\"}}]}", + MediaType.APPLICATION_JSON)); server.expect(requestTo(BASE + "/issue")) .andExpect(method(org.springframework.http.HttpMethod.POST)) .andExpect(jsonPath("$.fields.issuetype.id").value("10001")) .andExpect(jsonPath("$.fields.project.key").value("PAY")) + .andExpect(jsonPath("$.fields.customfield_10037.type").value("doc")) + .andExpect(jsonPath("$.fields.customfield_10037.content[0].content[0].text") + .value("Given ok, When login, Then home.")) .andRespond(withStatus(HttpStatus.CREATED) .body("{\"id\":\"42\",\"key\":\"PAY-42\",\"self\":\"https://acme.atlassian.net/rest/api/3/issue/42\"}") .contentType(MediaType.APPLICATION_JSON)); CreatedIssue created = client.createIssue(CTX, "PAY", "Historia", "Login con Google", - Map.of("type", "doc", "version", 1, "content", List.of())); + Map.of("type", "doc", "version", 1, "content", List.of()), + "Given ok, When login, Then home."); assertThat(created.key()).isEqualTo("PAY-42"); assertThat(created.id()).isEqualTo("42"); @@ -92,6 +103,8 @@ void create_surfaces_jira_error_body() { server.expect(requestTo(BASE + "/issue/createmeta/PAY/issuetypes?maxResults=200")) .andRespond(withSuccess("{\"issueTypes\":[{\"id\":\"10001\",\"name\":\"Historia\"}]}", MediaType.APPLICATION_JSON)); + server.expect(requestTo(BASE + "/issue/createmeta/PAY/issuetypes/10001?maxResults=200")) + .andRespond(withSuccess("{\"fields\":[]}", MediaType.APPLICATION_JSON)); server.expect(requestTo(BASE + "/issue")) .andRespond(withStatus(HttpStatus.BAD_REQUEST) .body("{\"errorMessages\":[\"Field 'customfield_10011' is required\"]," @@ -99,7 +112,7 @@ void create_surfaces_jira_error_body() { .contentType(MediaType.APPLICATION_JSON)); assertThatThrownBy(() -> client.createIssue(CTX, "PAY", "Historia", "x", - Map.of("type", "doc", "version", 1, "content", List.of()))) + Map.of("type", "doc", "version", 1, "content", List.of()), "")) .isInstanceOf(InfrastructureException.class) .hasMessageContaining("Field 'customfield_10011' is required") .hasMessageContaining("summary: Summary must be provided."); From 678054d414b29988c11bdfaa78972eec43271476 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Thu, 9 Jul 2026 21:41:08 -0500 Subject: [PATCH 55/72] feat(gateway): add durable integration sync job aggregate One integration_sync_jobs row per async Jira import/push-all run: counters as source of truth, partial unique index enforcing a single RUNNING job per project+type, and 409/404 error codes for the job endpoints. --- .../port/IntegrationSyncJobRepository.java | 28 ++++ .../domain/exception/IntegrationsError.java | 6 + .../exception/IntegrationsExceptions.java | 12 ++ .../domain/model/IntegrationSyncJob.java | 142 ++++++++++++++++++ .../model/IntegrationSyncJobStatus.java | 12 ++ .../domain/model/IntegrationSyncJobType.java | 11 ++ .../IntegrationSyncJobRepositoryAdapter.java | 46 ++++++ .../IntegrationSyncJobJpaRepository.java | 21 +++ ...V20260709100000__integration_sync_jobs.sql | 27 ++++ 9 files changed, 305 insertions(+) create mode 100644 src/main/java/com/kntro/reqsai/gateway/application/port/IntegrationSyncJobRepository.java create mode 100644 src/main/java/com/kntro/reqsai/gateway/domain/model/IntegrationSyncJob.java create mode 100644 src/main/java/com/kntro/reqsai/gateway/domain/model/IntegrationSyncJobStatus.java create mode 100644 src/main/java/com/kntro/reqsai/gateway/domain/model/IntegrationSyncJobType.java create mode 100644 src/main/java/com/kntro/reqsai/gateway/infrastructure/persistence/adapters/IntegrationSyncJobRepositoryAdapter.java create mode 100644 src/main/java/com/kntro/reqsai/gateway/infrastructure/persistence/repositories/IntegrationSyncJobJpaRepository.java create mode 100644 src/main/resources/db/migration/tenant/V20260709100000__integration_sync_jobs.sql diff --git a/src/main/java/com/kntro/reqsai/gateway/application/port/IntegrationSyncJobRepository.java b/src/main/java/com/kntro/reqsai/gateway/application/port/IntegrationSyncJobRepository.java new file mode 100644 index 00000000..4ddfed68 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/port/IntegrationSyncJobRepository.java @@ -0,0 +1,28 @@ +package com.kntro.reqsai.gateway.application.port; + +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJob; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJobType; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +/** + * Persistence port for {@link IntegrationSyncJob} rows — the durable state behind the async Jira + * import / push-all endpoints. Implemented by {@code IntegrationSyncJobRepositoryAdapter}. + */ +public interface IntegrationSyncJobRepository { + + IntegrationSyncJob save(IntegrationSyncJob job); + + Optional findById(UUID id); + + /** Whether a {@code RUNNING} job of {@code type} already exists for the project (409 guard). */ + boolean existsRunning(UUID projectId, IntegrationSyncJobType type); + + /** The project's {@code RUNNING} jobs, newest first (reload recovery). */ + List findRunning(UUID projectId); + + /** The project's most recent jobs (any status, newest first, bounded to ~10). */ + List findRecent(UUID projectId); +} diff --git a/src/main/java/com/kntro/reqsai/gateway/domain/exception/IntegrationsError.java b/src/main/java/com/kntro/reqsai/gateway/domain/exception/IntegrationsError.java index 42c19691..e2d54c22 100644 --- a/src/main/java/com/kntro/reqsai/gateway/domain/exception/IntegrationsError.java +++ b/src/main/java/com/kntro/reqsai/gateway/domain/exception/IntegrationsError.java @@ -13,6 +13,12 @@ public enum IntegrationsError implements ErrorCatalog { INTEGRATION_CONNECTION_NOT_FOUND(HttpStatus.NOT_FOUND), INTEGRATION_ALREADY_CONNECTED(HttpStatus.CONFLICT), INTEGRATION_TARGET_NOT_CONFIGURED(HttpStatus.CONFLICT), + + /** A background sync job (import / push-all) of the same type is already RUNNING for the project. */ + INTEGRATION_JOB_ALREADY_RUNNING(HttpStatus.CONFLICT), + + /** No sync job with the requested id exists for the project. */ + INTEGRATION_JOB_NOT_FOUND(HttpStatus.NOT_FOUND), JIRA_PROJECT_NOT_FOUND(HttpStatus.NOT_FOUND), /** Jira OAuth 2.0 (3LO) is not configured on this deployment (client id/secret/redirect absent). */ diff --git a/src/main/java/com/kntro/reqsai/gateway/domain/exception/IntegrationsExceptions.java b/src/main/java/com/kntro/reqsai/gateway/domain/exception/IntegrationsExceptions.java index d922da97..99715c58 100644 --- a/src/main/java/com/kntro/reqsai/gateway/domain/exception/IntegrationsExceptions.java +++ b/src/main/java/com/kntro/reqsai/gateway/domain/exception/IntegrationsExceptions.java @@ -37,6 +37,18 @@ public static DomainException targetNotConfigured(UUID projectId) { "No integration target configured for project " + projectId); } + /** A RUNNING sync job of the same type already exists for the project — 409 for the job-start endpoints. */ + public static DomainException jobAlreadyRunning(UUID projectId, String jobType) { + return new DomainException(IntegrationsError.INTEGRATION_JOB_ALREADY_RUNNING, + "A %s job is already running for project %s".formatted(jobType, projectId)); + } + + /** No sync job with the given id exists for the project — 404 for the job query endpoint. */ + public static EntityNotFoundException jobNotFound(UUID jobId) { + return new EntityNotFoundException(IntegrationsError.INTEGRATION_JOB_NOT_FOUND, + "Integration sync job not found: " + jobId); + } + public static EntityNotFoundException jiraProjectNotFound(String jiraProjectKey) { return new EntityNotFoundException(IntegrationsError.JIRA_PROJECT_NOT_FOUND, "Jira project not found: " + jiraProjectKey); diff --git a/src/main/java/com/kntro/reqsai/gateway/domain/model/IntegrationSyncJob.java b/src/main/java/com/kntro/reqsai/gateway/domain/model/IntegrationSyncJob.java new file mode 100644 index 00000000..c8b4c3e8 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/domain/model/IntegrationSyncJob.java @@ -0,0 +1,142 @@ +package com.kntro.reqsai.gateway.domain.model; + +import com.kntro.reqsai.shared.domain.model.AggregateRoot; +import com.kntro.reqsai.shared.domain.support.Assert; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Table; +import lombok.Getter; +import org.jspecify.annotations.Nullable; + +import java.time.Instant; +import java.util.UUID; + +/** + * Durable background sync job (ADR-0023): one row per async Jira {@code IMPORT} / {@code PUSH_ALL} + * run. The row is the source of truth for progress — the async worker updates the + * counters per item and mirrors every update to STOMP, so a page reload recovers the live state by + * querying the job endpoints. + * + *

              Counting rules: {@code processed} counts every handled item; {@code succeeded} the created/pushed + * ones; {@code failed} the per-item failures (which never abort the run). An import duplicate counts + * toward {@code processed} only (skipped — neither succeeded nor failed). Terminal transitions set + * {@code finishedAt} and an optional bounded {@code message} (fatal-error summary or skip note). + */ +@Entity +@Table(name = "integration_sync_jobs") +@Getter +public class IntegrationSyncJob extends AggregateRoot { + + private static final int MESSAGE_MAX = 1000; + + @Column(name = "project_id", columnDefinition = "uuid", nullable = false, updatable = false) + private UUID projectId; + + @Enumerated(EnumType.STRING) + @Column(name = "job_type", nullable = false, length = 16, updatable = false) + private IntegrationSyncJobType jobType; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 16) + private IntegrationSyncJobStatus status; + + @Column(name = "total", nullable = false) + private int total; + + @Column(name = "processed", nullable = false) + private int processed; + + @Column(name = "succeeded", nullable = false) + private int succeeded; + + @Column(name = "failed", nullable = false) + private int failed; + + @Column(name = "message", length = MESSAGE_MAX) + @Nullable + private String message; + + @Column(name = "requested_by", columnDefinition = "uuid", updatable = false) + @Nullable + private UUID requestedBy; + + @Column(name = "finished_at") + @Nullable + private Instant finishedAt; + + protected IntegrationSyncJob() { + super(); + } + + /** Starts a new job in {@code RUNNING} state. {@code total} may be 0 until the worker resolves it. */ + public IntegrationSyncJob(UUID projectId, IntegrationSyncJobType jobType, int total, @Nullable UUID requestedBy) { + super(); + this.projectId = Assert.notNull(projectId, "projectId"); + this.jobType = Assert.notNull(jobType, "jobType"); + this.status = IntegrationSyncJobStatus.RUNNING; + this.total = Math.max(0, total); + this.requestedBy = requestedBy; + } + + public boolean isRunning() { + return status == IntegrationSyncJobStatus.RUNNING; + } + + /** Fixes the item count once the worker knows how many items it will process. */ + public void planTotal(int total) { + assertRunning(); + this.total = Math.max(0, total); + } + + /** One item created/pushed successfully. */ + public void recordSuccess() { + assertRunning(); + processed++; + succeeded++; + } + + /** One item failed (the run continues). */ + public void recordFailure() { + assertRunning(); + processed++; + failed++; + } + + /** One item skipped (e.g. an import duplicate): processed, but neither succeeded nor failed. */ + public void recordSkipped() { + assertRunning(); + processed++; + } + + /** Terminal success (per-item failures allowed); {@code message} is an optional summary note. */ + public void complete(@Nullable String message) { + assertRunning(); + this.status = IntegrationSyncJobStatus.COMPLETED; + this.message = truncate(message); + this.finishedAt = Instant.now(); + } + + /** Terminal fatal failure (e.g. the tracker was unreachable before/while iterating). */ + public void fail(@Nullable String message) { + assertRunning(); + this.status = IntegrationSyncJobStatus.FAILED; + this.message = truncate(message); + this.finishedAt = Instant.now(); + } + + private void assertRunning() { + if (!isRunning()) { + throw new IllegalStateException("Job " + getId() + " is terminal (" + status + ")"); + } + } + + @Nullable + private static String truncate(@Nullable String message) { + if (message == null || message.length() <= MESSAGE_MAX) { + return message; + } + return message.substring(0, MESSAGE_MAX); + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/domain/model/IntegrationSyncJobStatus.java b/src/main/java/com/kntro/reqsai/gateway/domain/model/IntegrationSyncJobStatus.java new file mode 100644 index 00000000..542a2f4c --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/domain/model/IntegrationSyncJobStatus.java @@ -0,0 +1,12 @@ +package com.kntro.reqsai.gateway.domain.model; + +/** + * Lifecycle of an {@link IntegrationSyncJob}: born {@code RUNNING}, ends {@code COMPLETED} (per-item + * failures allowed) or {@code FAILED} (fatal error, e.g. the tracker was unreachable). Persisted as + * the wire value ({@code VARCHAR(16)}), so names are part of the API contract. + */ +public enum IntegrationSyncJobStatus { + RUNNING, + COMPLETED, + FAILED +} diff --git a/src/main/java/com/kntro/reqsai/gateway/domain/model/IntegrationSyncJobType.java b/src/main/java/com/kntro/reqsai/gateway/domain/model/IntegrationSyncJobType.java new file mode 100644 index 00000000..18eb09e8 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/domain/model/IntegrationSyncJobType.java @@ -0,0 +1,11 @@ +package com.kntro.reqsai.gateway.domain.model; + +/** + * What an {@link IntegrationSyncJob} does: {@code IMPORT} pulls tracker issues into the backlog as + * user stories; {@code PUSH_ALL} exports every project story to the tracker. Persisted as the wire + * value ({@code VARCHAR(16)}), so names are part of the API contract. + */ +public enum IntegrationSyncJobType { + IMPORT, + PUSH_ALL +} diff --git a/src/main/java/com/kntro/reqsai/gateway/infrastructure/persistence/adapters/IntegrationSyncJobRepositoryAdapter.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/persistence/adapters/IntegrationSyncJobRepositoryAdapter.java new file mode 100644 index 00000000..183a3ef3 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/persistence/adapters/IntegrationSyncJobRepositoryAdapter.java @@ -0,0 +1,46 @@ +package com.kntro.reqsai.gateway.infrastructure.persistence.adapters; + +import com.kntro.reqsai.gateway.application.port.IntegrationSyncJobRepository; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJob; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJobStatus; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJobType; +import com.kntro.reqsai.gateway.infrastructure.persistence.repositories.IntegrationSyncJobJpaRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +/** Adapts the {@link IntegrationSyncJobRepository} port to Spring Data JPA. */ +@Component +@RequiredArgsConstructor +public class IntegrationSyncJobRepositoryAdapter implements IntegrationSyncJobRepository { + + private final IntegrationSyncJobJpaRepository jpa; + + @Override + public IntegrationSyncJob save(IntegrationSyncJob job) { + return jpa.save(job); + } + + @Override + public Optional findById(UUID id) { + return jpa.findById(id); + } + + @Override + public boolean existsRunning(UUID projectId, IntegrationSyncJobType type) { + return jpa.existsByProjectIdAndJobTypeAndStatus(projectId, type, IntegrationSyncJobStatus.RUNNING); + } + + @Override + public List findRunning(UUID projectId) { + return jpa.findByProjectIdAndStatusOrderByCreatedAtDesc(projectId, IntegrationSyncJobStatus.RUNNING); + } + + @Override + public List findRecent(UUID projectId) { + return jpa.findTop10ByProjectIdOrderByCreatedAtDesc(projectId); + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/infrastructure/persistence/repositories/IntegrationSyncJobJpaRepository.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/persistence/repositories/IntegrationSyncJobJpaRepository.java new file mode 100644 index 00000000..792d11f8 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/persistence/repositories/IntegrationSyncJobJpaRepository.java @@ -0,0 +1,21 @@ +package com.kntro.reqsai.gateway.infrastructure.persistence.repositories; + +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJob; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJobStatus; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJobType; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.UUID; + +/** Spring Data repository for {@link IntegrationSyncJob}; internal to the persistence adapter. */ +public interface IntegrationSyncJobJpaRepository extends JpaRepository { + + boolean existsByProjectIdAndJobTypeAndStatus( + UUID projectId, IntegrationSyncJobType jobType, IntegrationSyncJobStatus status); + + List findByProjectIdAndStatusOrderByCreatedAtDesc( + UUID projectId, IntegrationSyncJobStatus status); + + List findTop10ByProjectIdOrderByCreatedAtDesc(UUID projectId); +} diff --git a/src/main/resources/db/migration/tenant/V20260709100000__integration_sync_jobs.sql b/src/main/resources/db/migration/tenant/V20260709100000__integration_sync_jobs.sql new file mode 100644 index 00000000..71ce14d9 --- /dev/null +++ b/src/main/resources/db/migration/tenant/V20260709100000__integration_sync_jobs.sql @@ -0,0 +1,27 @@ +-- Durable integration sync jobs (ADR-0023): async Jira import / push-all runs persisted per project. +-- The job row is the source of truth for progress; STOMP pushes mirror it and a reload recovers from it. + +CREATE TABLE integration_sync_jobs ( + id UUID NOT NULL PRIMARY KEY, + project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + job_type VARCHAR(16) NOT NULL, -- IMPORT | PUSH_ALL + status VARCHAR(16) NOT NULL, -- RUNNING | COMPLETED | FAILED + total INT NOT NULL DEFAULT 0, + processed INT NOT NULL DEFAULT 0, + succeeded INT NOT NULL DEFAULT 0, + failed INT NOT NULL DEFAULT 0, + message VARCHAR(1000), + requested_by UUID, + finished_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_by UUID, + updated_by UUID +); + +CREATE INDEX idx_integration_sync_jobs_project_status ON integration_sync_jobs (project_id, status); + +-- At most one RUNNING job per (project, type): a concurrent start gets 409 INTEGRATION_JOB_ALREADY_RUNNING. +CREATE UNIQUE INDEX uq_integration_sync_jobs_running + ON integration_sync_jobs (project_id, job_type) + WHERE status = 'RUNNING'; From 01a5085b0ce5c4ee8f939431c7ac2083a5fd5db9 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Thu, 9 Jul 2026 21:43:12 -0500 Subject: [PATCH 56/72] feat(gateway): run jira sync jobs on a tenant-aware async worker IntegrationSyncJobRunner captures the caller's TenantContext snapshot and restores it with TenantContext.runWith on the shared taskExecutor (same pattern as TenantAwareModuleListener), reusing the existing import/push mechanics per item and mirroring every persisted counter update to /topic/projects/{id}/integration-jobs via RealtimeNotifier. --- .../IntegrationJobProgressNotifier.java | 25 +++ .../notification/IntegrationJobTopics.java | 37 ++++ .../service/IntegrationSyncJobRunner.java | 178 ++++++++++++++++++ .../service/IntegrationSyncJobStarter.java | 46 +++++ .../IntegrationJobNotificationMapper.java | 27 +++ .../messages/IntegrationJobMessage.java | 29 +++ 6 files changed, 342 insertions(+) create mode 100644 src/main/java/com/kntro/reqsai/gateway/application/notification/IntegrationJobProgressNotifier.java create mode 100644 src/main/java/com/kntro/reqsai/gateway/application/notification/IntegrationJobTopics.java create mode 100644 src/main/java/com/kntro/reqsai/gateway/application/service/IntegrationSyncJobRunner.java create mode 100644 src/main/java/com/kntro/reqsai/gateway/application/service/IntegrationSyncJobStarter.java create mode 100644 src/main/java/com/kntro/reqsai/gateway/interfaces/notification/mappers/IntegrationJobNotificationMapper.java create mode 100644 src/main/java/com/kntro/reqsai/gateway/interfaces/notification/messages/IntegrationJobMessage.java diff --git a/src/main/java/com/kntro/reqsai/gateway/application/notification/IntegrationJobProgressNotifier.java b/src/main/java/com/kntro/reqsai/gateway/application/notification/IntegrationJobProgressNotifier.java new file mode 100644 index 00000000..5df4762e --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/notification/IntegrationJobProgressNotifier.java @@ -0,0 +1,25 @@ +package com.kntro.reqsai.gateway.application.notification; + +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJob; +import com.kntro.reqsai.gateway.interfaces.notification.mappers.IntegrationJobNotificationMapper; +import com.kntro.reqsai.shared.application.notification.RealtimeNotifier; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/** + * Publishes a job's current state to its project topic ({@link IntegrationJobTopics#jobsOf}) after + * every persisted update. The worker publishes per item — at the current batch scale (tens of + * issues) no throttling is needed; the durable row remains the source of truth if a frame is lost + * (the shared {@link RealtimeNotifier} never propagates send failures). + */ +@Component +@RequiredArgsConstructor +public class IntegrationJobProgressNotifier { + + private final RealtimeNotifier notifier; + + public void publish(IntegrationSyncJob job) { + notifier.broadcast(IntegrationJobTopics.jobsOf(job.getProjectId()), + IntegrationJobNotificationMapper.toMessage(job)); + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/notification/IntegrationJobTopics.java b/src/main/java/com/kntro/reqsai/gateway/application/notification/IntegrationJobTopics.java new file mode 100644 index 00000000..525ace79 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/notification/IntegrationJobTopics.java @@ -0,0 +1,37 @@ +package com.kntro.reqsai.gateway.application.notification; + +import com.kntro.reqsai.shared.application.notification.RealtimeNotifier; + +import java.util.Objects; +import java.util.UUID; + +/** + * Single source of truth for the integration-job realtime destination. Like the + * discovery {@code ProjectTopics}, this is a logical topic name (no broker prefix) — the + * shared {@link RealtimeNotifier#broadcast(String, Object)} prepends {@code /topic}, so + * {@code jobsOf(id)} reaches subscribers on {@code /topic/projects/{id}/integration-jobs}. + * + *

              A viewer on any page of the project subscribes here to render the global progress banner for + * background Jira import / push-all jobs. Subscription auth follows the same model as the other + * {@code /topic/projects/{id}/...} topics: the STOMP CONNECT frame is JWT-authenticated by + * {@code StompAuthChannelInterceptor}; no per-destination gate exists, and none is added here. + */ +public final class IntegrationJobTopics { + + static final String PROJECTS_PREFIX = "projects/"; + static final String JOBS_SUFFIX = "/integration-jobs"; + + private IntegrationJobTopics() { + } + + /** + * Logical topic carrying every sync-job progress update of one project. + * + * @param projectId the project aggregate id (required) + * @return {@code "projects/{projectId}/integration-jobs"} — the notifier adds the {@code /topic} prefix + */ + public static String jobsOf(UUID projectId) { + Objects.requireNonNull(projectId, "projectId"); + return PROJECTS_PREFIX + projectId + JOBS_SUFFIX; + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/service/IntegrationSyncJobRunner.java b/src/main/java/com/kntro/reqsai/gateway/application/service/IntegrationSyncJobRunner.java new file mode 100644 index 00000000..92bd0aeb --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/service/IntegrationSyncJobRunner.java @@ -0,0 +1,178 @@ +package com.kntro.reqsai.gateway.application.service; + +import com.kntro.reqsai.discovery.api.DiscoveryStoryReadPort; +import com.kntro.reqsai.discovery.api.StoryView; +import com.kntro.reqsai.gateway.application.notification.IntegrationJobProgressNotifier; +import com.kntro.reqsai.gateway.application.port.IntegrationProvider.RemoteIssue; +import com.kntro.reqsai.gateway.application.port.IntegrationSyncJobRepository; +import com.kntro.reqsai.gateway.application.port.ProjectIntegrationTargetRepository; +import com.kntro.reqsai.gateway.application.result.ImportStoryResult; +import com.kntro.reqsai.gateway.application.service.StoryPushService.PushContext; +import com.kntro.reqsai.gateway.domain.exception.IntegrationsExceptions; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJob; +import com.kntro.reqsai.gateway.domain.model.ProjectIntegrationTarget; +import com.kntro.reqsai.shared.domain.exception.DomainException; +import com.kntro.reqsai.shared.infrastructure.persistence.multitenancy.TenantContext; +import com.kntro.reqsai.shared.infrastructure.persistence.multitenancy.TenantContext.TenantSnapshot; +import lombok.extern.slf4j.Slf4j; +import org.jspecify.annotations.Nullable; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.core.task.TaskExecutor; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Set; +import java.util.UUID; + +/** + * Executes Jira import / push-all jobs on the shared async executor, off the request thread. The + * request thread only creates the job row and calls {@code dispatch*}; the worker then reuses the + * existing per-item mechanics ({@link JiraImportService} / {@link StoryPushService}), persisting the + * job counters after every item and mirroring each update to STOMP via + * {@link IntegrationJobProgressNotifier}. + * + *

              Tenant propagation — the tenant schema lives in the {@link TenantContext} + * ThreadLocal, which the async thread does not inherit. Following the established pattern for + * threads with no filter-managed context (see {@code TenantContext.runWith} and + * {@code TenantAwareModuleListener}), {@code dispatch*} captures a {@link TenantSnapshot} on the + * request thread and the worker restores it with {@link TenantContext#runWith} before any DB access, + * so every Hibernate session the job opens resolves the caller's tenant schema. + * + *

              Failure semantics: per-item failures increment {@code failed} and the run continues; a fatal + * error (target/connection resolution, Jira fetch) marks the job {@code FAILED} with a bounded + * message. Import duplicates count toward {@code processed} only and are summarized in the terminal + * message. + */ +@Component +@Slf4j +public class IntegrationSyncJobRunner { + + private final TaskExecutor executor; + private final IntegrationSyncJobRepository jobs; + private final ProjectIntegrationTargetRepository targets; + private final JiraImportService importService; + private final StoryPushService pushService; + private final DiscoveryStoryReadPort stories; + private final IntegrationJobProgressNotifier progress; + + public IntegrationSyncJobRunner( + @Qualifier("taskExecutor") TaskExecutor executor, + IntegrationSyncJobRepository jobs, + ProjectIntegrationTargetRepository targets, + JiraImportService importService, + StoryPushService pushService, + DiscoveryStoryReadPort stories, + IntegrationJobProgressNotifier progress) { + this.executor = executor; + this.jobs = jobs; + this.targets = targets; + this.importService = importService; + this.pushService = pushService; + this.stories = stories; + this.progress = progress; + } + + /** Dispatches an import run, capturing the caller's tenant for the worker thread. */ + public void dispatchImport(UUID jobId, @Nullable List issueKeys) { + TenantSnapshot tenant = TenantContext.capture(); + executor.execute(() -> TenantContext.runWith(tenant, () -> runImport(jobId, issueKeys))); + } + + /** Dispatches a push-all run, capturing the caller's tenant for the worker thread. */ + public void dispatchPushAll(UUID jobId) { + TenantSnapshot tenant = TenantContext.capture(); + executor.execute(() -> TenantContext.runWith(tenant, () -> runPushAll(jobId))); + } + + private void runImport(UUID jobId, @Nullable List issueKeys) { + IntegrationSyncJob job = jobs.findById(jobId).orElse(null); + if (job == null) { + log.error("Import job {} vanished before the worker started", jobId); + return; + } + try { + PushContext ctx = importService.contextFor(requireTarget(job.getProjectId())); + List issues = importService.fetchIssues(ctx); + + Set requested = issueKeys == null ? Set.of() : Set.copyOf(issueKeys); + List selected = issues.stream() + .filter(issue -> requested.isEmpty() || requested.contains(issue.issueKey())) + .toList(); + job.planTotal(selected.size()); + saveAndPublish(job); + + int duplicates = 0; + for (RemoteIssue issue : selected) { + // importIssue captures per-issue failures itself, so one bad issue never aborts the run. + ImportStoryResult result = importService.importIssue(job.getProjectId(), issue); + switch (result.status()) { + case IMPORTED -> job.recordSuccess(); + case FAILED -> job.recordFailure(); + case DUPLICATE -> { + job.recordSkipped(); + duplicates++; + } + } + saveAndPublish(job); + } + job.complete(duplicates > 0 ? duplicates + " duplicados omitidos" : null); + saveAndPublish(job); + log.info("Import job {} completed: {}/{} succeeded, {} failed, {} duplicates", + jobId, job.getSucceeded(), job.getTotal(), job.getFailed(), duplicates); + } catch (RuntimeException e) { + failJob(job, e); + } + } + + private void runPushAll(UUID jobId) { + IntegrationSyncJob job = jobs.findById(jobId).orElse(null); + if (job == null) { + log.error("Push-all job {} vanished before the worker started", jobId); + return; + } + try { + PushContext ctx = pushService.contextFor(requireTarget(job.getProjectId())); + List all = stories.listStories(job.getProjectId()); + job.planTotal(all.size()); + saveAndPublish(job); + + for (StoryView story : all) { + try { + pushService.push(ctx, story); + job.recordSuccess(); + } catch (DomainException e) { + // One story's provider failure must not abort the rest of the batch. + log.warn("Push failed for story {} [{}]", story.storyId(), e.error().code()); + job.recordFailure(); + } + saveAndPublish(job); + } + job.complete(null); + saveAndPublish(job); + log.info("Push-all job {} completed: {}/{} succeeded, {} failed", + jobId, job.getSucceeded(), job.getTotal(), job.getFailed()); + } catch (RuntimeException e) { + failJob(job, e); + } + } + + private ProjectIntegrationTarget requireTarget(UUID projectId) { + return targets.findByProjectId(projectId) + .orElseThrow(() -> IntegrationsExceptions.targetNotConfigured(projectId)); + } + + private void saveAndPublish(IntegrationSyncJob job) { + progress.publish(jobs.save(job)); + } + + /** Terminal fatal path: persist FAILED + message and publish, never letting the worker throw. */ + private void failJob(IntegrationSyncJob job, RuntimeException cause) { + log.error("Integration job {} failed fatally", job.getId(), cause); + try { + job.fail(cause.getMessage()); + saveAndPublish(job); + } catch (RuntimeException e) { + log.error("Could not persist failure of integration job {}", job.getId(), e); + } + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/service/IntegrationSyncJobStarter.java b/src/main/java/com/kntro/reqsai/gateway/application/service/IntegrationSyncJobStarter.java new file mode 100644 index 00000000..8cd3895e --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/service/IntegrationSyncJobStarter.java @@ -0,0 +1,46 @@ +package com.kntro.reqsai.gateway.application.service; + +import com.kntro.reqsai.gateway.application.port.IntegrationSyncJobRepository; +import com.kntro.reqsai.gateway.domain.exception.IntegrationsExceptions; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJob; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJobType; +import lombok.RequiredArgsConstructor; +import org.jspecify.annotations.Nullable; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Component; + +import java.util.UUID; + +/** + * Creates the durable {@code RUNNING} job row shared by both async endpoints, enforcing the + * single-running-job rule twice: a cheap pre-check (the common 409 path) and the partial unique + * index {@code uq_integration_sync_jobs_running} as the race-proof backstop (a concurrent insert + * surfaces as the same 409). Deliberately not wrapped in a caller transaction: + * the save commits on its own, so the row is visible to the async worker (and to job queries) + * before the worker is dispatched. + */ +@Component +@RequiredArgsConstructor +public class IntegrationSyncJobStarter { + + private final IntegrationSyncJobRepository jobs; + + /** + * Persists a new {@code RUNNING} job of {@code type} for the project. + * + * @param total the item count if already known (selected issue keys / story count), else 0 + * @throws com.kntro.reqsai.shared.domain.exception.DomainException 409 + * {@code INTEGRATION_JOB_ALREADY_RUNNING} when a job of the same type is running + */ + public IntegrationSyncJob start(UUID projectId, IntegrationSyncJobType type, int total, @Nullable UUID requestedBy) { + if (jobs.existsRunning(projectId, type)) { + throw IntegrationsExceptions.jobAlreadyRunning(projectId, type.name()); + } + try { + return jobs.save(new IntegrationSyncJob(projectId, type, total, requestedBy)); + } catch (DataIntegrityViolationException e) { + // Two requests raced past the pre-check; the partial unique index kept exactly one. + throw IntegrationsExceptions.jobAlreadyRunning(projectId, type.name()); + } + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/interfaces/notification/mappers/IntegrationJobNotificationMapper.java b/src/main/java/com/kntro/reqsai/gateway/interfaces/notification/mappers/IntegrationJobNotificationMapper.java new file mode 100644 index 00000000..f709d085 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/interfaces/notification/mappers/IntegrationJobNotificationMapper.java @@ -0,0 +1,27 @@ +package com.kntro.reqsai.gateway.interfaces.notification.mappers; + +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJob; +import com.kntro.reqsai.gateway.interfaces.notification.messages.IntegrationJobMessage; + +/** Maps an {@link IntegrationSyncJob} snapshot to its realtime {@link IntegrationJobMessage}. */ +public final class IntegrationJobNotificationMapper { + + private IntegrationJobNotificationMapper() { + throw new UnsupportedOperationException("Utility class - do not instantiate"); + } + + public static IntegrationJobMessage toMessage(IntegrationSyncJob job) { + return new IntegrationJobMessage( + job.getId(), + job.getProjectId(), + job.getJobType(), + job.getStatus(), + job.getTotal(), + job.getProcessed(), + job.getSucceeded(), + job.getFailed(), + job.getMessage(), + job.getCreatedAt(), + job.getFinishedAt()); + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/interfaces/notification/messages/IntegrationJobMessage.java b/src/main/java/com/kntro/reqsai/gateway/interfaces/notification/messages/IntegrationJobMessage.java new file mode 100644 index 00000000..354d0f0d --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/interfaces/notification/messages/IntegrationJobMessage.java @@ -0,0 +1,29 @@ +package com.kntro.reqsai.gateway.interfaces.notification.messages; + +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJobStatus; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJobType; +import org.jspecify.annotations.Nullable; + +import java.time.Instant; +import java.util.UUID; + +/** + * WebSocket payload broadcast on {@code /topic/projects/{projectId}/integration-jobs} for every + * sync-job progress update (per item) and terminal transition. The JSON shape is deliberately + * identical to the REST {@code IntegrationJobResponse}, so the frontend renders the + * same object whether it arrives live over STOMP or from the reload-recovery job query endpoints. + */ +public record IntegrationJobMessage( + UUID id, + UUID projectId, + IntegrationSyncJobType jobType, + IntegrationSyncJobStatus status, + int total, + int processed, + int succeeded, + int failed, + @Nullable String message, + Instant createdAt, + @Nullable Instant finishedAt +) { +} From be9d268ced83448bca04e2dbe1ce28ee0a4e8cd2 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Thu, 9 Jul 2026 21:53:17 -0500 Subject: [PATCH 57/72] feat(shared): persist spring batch metadata in the public schema Adds the batch starter with a JdbcDefaultBatchConfiguration subclass: tablePrefix public.BATCH_ schema-qualifies every JobRepository query (the DataSource rewrites search_path per tenant), the DDL is owned by a common Flyway migration, the JobOperator runs on the shared taskExecutor for 202-style launches, and startup job replay is disabled. --- build.gradle.kts | 1 + .../configuration/BatchConfiguration.java | 49 +++++++++++ src/main/resources/application.yml | 7 ++ ...V20260709100001__spring_batch_metadata.sql | 86 +++++++++++++++++++ 4 files changed, 143 insertions(+) create mode 100644 src/main/java/com/kntro/reqsai/shared/infrastructure/configuration/BatchConfiguration.java create mode 100644 src/main/resources/db/migration/common/V20260709100001__spring_batch_metadata.sql diff --git a/build.gradle.kts b/build.gradle.kts index 33b4c714..8a5e8b2d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -63,6 +63,7 @@ dependencies { // DATA + DB // ================================== implementation("org.springframework.boot:spring-boot-starter-data-jpa") + implementation("org.springframework.boot:spring-boot-starter-batch") implementation("org.springframework.boot:spring-boot-starter-flyway") implementation("org.flywaydb:flyway-database-postgresql") implementation("org.hibernate.orm:hibernate-vector") diff --git a/src/main/java/com/kntro/reqsai/shared/infrastructure/configuration/BatchConfiguration.java b/src/main/java/com/kntro/reqsai/shared/infrastructure/configuration/BatchConfiguration.java new file mode 100644 index 00000000..21d330ac --- /dev/null +++ b/src/main/java/com/kntro/reqsai/shared/infrastructure/configuration/BatchConfiguration.java @@ -0,0 +1,49 @@ +package com.kntro.reqsai.shared.infrastructure.configuration; + +import org.springframework.batch.core.configuration.support.JdbcDefaultBatchConfiguration; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.task.TaskExecutor; + +/** + * Spring Batch runtime for the async integration sync jobs (ADR-0023). Extending + * {@link JdbcDefaultBatchConfiguration} opts into durable batch metadata (Spring + * Batch 6 / Boot 4 default to an in-memory "resourceless" {@code JobRepository}) and makes Boot's + * batch auto-configuration back off, so this class is the single source of truth: + * + *

                + *
              • Metadata lives in the {@code public} schema. The app's single DataSource is + * routed per tenant by rewriting {@code search_path}, so unqualified {@code BATCH_*} SQL could + * hit an arbitrary tenant schema. The {@code public.BATCH_} table prefix schema-qualifies every + * {@code JobRepository} query (tables and sequences alike), and the matching DDL is owned by + * the common Flyway migration {@code V20260709100001__spring_batch_metadata.sql}. Batch + * metadata is operational — global like {@code public.organizations} — while the domain-facing + * job state stays in the per-tenant {@code integration_sync_jobs} projection.
              • + *
              • Asynchronous launches. The {@code JobOperator} built by this configuration is + * a {@code TaskExecutorJobOperator} running on the shared {@code taskExecutor} (virtual + * threads), so {@code start(job, parameters)} registers the execution and returns immediately — + * that is what lets the REST endpoints answer {@code 202 Accepted} while the job runs.
              • + *
              + * + *

              {@code spring.batch.job.enabled=false} keeps Boot from replaying registered jobs at startup; + * jobs run only when the API launches them with explicit parameters. + */ +@Configuration +public class BatchConfiguration extends JdbcDefaultBatchConfiguration { + + private final TaskExecutor taskExecutor; + + public BatchConfiguration(@Qualifier("taskExecutor") TaskExecutor taskExecutor) { + this.taskExecutor = taskExecutor; + } + + @Override + protected String getTablePrefix() { + return "public.BATCH_"; + } + + @Override + protected TaskExecutor getTaskExecutor() { + return taskExecutor; + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index fcc7e6db..916b36c8 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -48,6 +48,13 @@ spring: validate-on-migrate: true out-of-order: true + # Spring Batch runs the async integration sync jobs (Jira import / push-all). Jobs are launched + # on demand by the API — never at startup, hence enabled: false. Metadata DDL is owned by the + # common Flyway migrations (public.batch_*); Boot 4 does not auto-initialize a batch schema. + batch: + job: + enabled: false + mail: host: ${MAIL_HOST:smtp.gmail.com} port: ${MAIL_PORT:587} diff --git a/src/main/resources/db/migration/common/V20260709100001__spring_batch_metadata.sql b/src/main/resources/db/migration/common/V20260709100001__spring_batch_metadata.sql new file mode 100644 index 00000000..f030ceb5 --- /dev/null +++ b/src/main/resources/db/migration/common/V20260709100001__spring_batch_metadata.sql @@ -0,0 +1,86 @@ +-- Spring Batch job-repository metadata (ADR-0023, async integration sync jobs). +-- +-- Copied verbatim from spring-batch-core 6.0.x `org/springframework/batch/core/schema-postgresql.sql`. +-- These tables live in the GLOBAL `public` schema on purpose: the app's DataSource routes +-- connections per tenant by rewriting `search_path`, so unqualified batch DDL/DML could land in +-- whatever tenant schema happens to be active. This migration runs with Flyway's `schemas: public` +-- (objects created in public) and the runtime JobRepository/JobOperator are configured with +-- tablePrefix `public.BATCH_` so every metadata query is schema-qualified regardless of search_path. +-- Domain-facing job state remains the per-tenant `integration_sync_jobs` projection. + +CREATE TABLE BATCH_JOB_INSTANCE ( + JOB_INSTANCE_ID BIGINT NOT NULL PRIMARY KEY, + VERSION BIGINT, + JOB_NAME VARCHAR(100) NOT NULL, + JOB_KEY VARCHAR(32) NOT NULL, + constraint JOB_INST_UN unique (JOB_NAME, JOB_KEY) +); + +CREATE TABLE BATCH_JOB_EXECUTION ( + JOB_EXECUTION_ID BIGINT NOT NULL PRIMARY KEY, + VERSION BIGINT, + JOB_INSTANCE_ID BIGINT NOT NULL, + CREATE_TIME TIMESTAMP NOT NULL, + START_TIME TIMESTAMP DEFAULT NULL, + END_TIME TIMESTAMP DEFAULT NULL, + STATUS VARCHAR(10), + EXIT_CODE VARCHAR(2500), + EXIT_MESSAGE VARCHAR(2500), + LAST_UPDATED TIMESTAMP, + constraint JOB_INST_EXEC_FK foreign key (JOB_INSTANCE_ID) + references BATCH_JOB_INSTANCE(JOB_INSTANCE_ID) +); + +CREATE TABLE BATCH_JOB_EXECUTION_PARAMS ( + JOB_EXECUTION_ID BIGINT NOT NULL, + PARAMETER_NAME VARCHAR(100) NOT NULL, + PARAMETER_TYPE VARCHAR(100) NOT NULL, + PARAMETER_VALUE VARCHAR(2500), + IDENTIFYING CHAR(1) NOT NULL, + constraint JOB_EXEC_PARAMS_FK foreign key (JOB_EXECUTION_ID) + references BATCH_JOB_EXECUTION(JOB_EXECUTION_ID) +); + +CREATE TABLE BATCH_STEP_EXECUTION ( + STEP_EXECUTION_ID BIGINT NOT NULL PRIMARY KEY, + VERSION BIGINT NOT NULL, + STEP_NAME VARCHAR(100) NOT NULL, + JOB_EXECUTION_ID BIGINT NOT NULL, + CREATE_TIME TIMESTAMP NOT NULL, + START_TIME TIMESTAMP DEFAULT NULL, + END_TIME TIMESTAMP DEFAULT NULL, + STATUS VARCHAR(10), + COMMIT_COUNT BIGINT, + READ_COUNT BIGINT, + FILTER_COUNT BIGINT, + WRITE_COUNT BIGINT, + READ_SKIP_COUNT BIGINT, + WRITE_SKIP_COUNT BIGINT, + PROCESS_SKIP_COUNT BIGINT, + ROLLBACK_COUNT BIGINT, + EXIT_CODE VARCHAR(2500), + EXIT_MESSAGE VARCHAR(2500), + LAST_UPDATED TIMESTAMP, + constraint JOB_EXEC_STEP_FK foreign key (JOB_EXECUTION_ID) + references BATCH_JOB_EXECUTION(JOB_EXECUTION_ID) +); + +CREATE TABLE BATCH_STEP_EXECUTION_CONTEXT ( + STEP_EXECUTION_ID BIGINT NOT NULL PRIMARY KEY, + SHORT_CONTEXT VARCHAR(2500) NOT NULL, + SERIALIZED_CONTEXT TEXT, + constraint STEP_EXEC_CTX_FK foreign key (STEP_EXECUTION_ID) + references BATCH_STEP_EXECUTION(STEP_EXECUTION_ID) +); + +CREATE TABLE BATCH_JOB_EXECUTION_CONTEXT ( + JOB_EXECUTION_ID BIGINT NOT NULL PRIMARY KEY, + SHORT_CONTEXT VARCHAR(2500) NOT NULL, + SERIALIZED_CONTEXT TEXT, + constraint JOB_EXEC_CTX_FK foreign key (JOB_EXECUTION_ID) + references BATCH_JOB_EXECUTION(JOB_EXECUTION_ID) +); + +CREATE SEQUENCE BATCH_STEP_EXECUTION_SEQ MAXVALUE 9223372036854775807 NO CYCLE; +CREATE SEQUENCE BATCH_JOB_EXECUTION_SEQ MAXVALUE 9223372036854775807 NO CYCLE; +CREATE SEQUENCE BATCH_JOB_INSTANCE_SEQ MAXVALUE 9223372036854775807 NO CYCLE; From 93979ec0daf2b480369d86e9fba63aded736183f Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Thu, 9 Jul 2026 21:59:34 -0500 Subject: [PATCH 58/72] refactor(gateway): drive jira sync jobs through spring batch Replaces the hand-rolled async worker with two chunk-oriented batch jobs (jiraImportJob / jiraPushAllJob): step-scoped readers resolve the work list and plan the projection total, processors delegate per item to the existing import/push services, a fault-tolerant skip policy preserves the per-item failure semantics, and job/step listeners restore the tenant from job parameters, update the integration_sync_jobs projection and publish each snapshot over STOMP. The launcher adapter behind the IntegrationJobLauncher port captures the tenant into job parameters and starts executions asynchronously via the JobOperator. --- .../port/IntegrationJobLauncher.java | 22 ++ .../service/IntegrationSyncJobRunner.java | 178 -------------- .../IntegrationBatchJobsConfiguration.java | 218 ++++++++++++++++++ .../IntegrationJobExecutionListener.java | 92 ++++++++ .../batch/IntegrationJobLauncherAdapter.java | 90 ++++++++ .../batch/IntegrationJobParameters.java | 51 ++++ .../batch/IntegrationJobProgressListener.java | 68 ++++++ .../batch/JiraImportItemProcessor.java | 33 +++ .../batch/JiraStoryPushItemProcessor.java | 27 +++ .../infrastructure/batch/SyncItemOutcome.java | 13 ++ 10 files changed, 614 insertions(+), 178 deletions(-) create mode 100644 src/main/java/com/kntro/reqsai/gateway/application/port/IntegrationJobLauncher.java delete mode 100644 src/main/java/com/kntro/reqsai/gateway/application/service/IntegrationSyncJobRunner.java create mode 100644 src/main/java/com/kntro/reqsai/gateway/infrastructure/batch/IntegrationBatchJobsConfiguration.java create mode 100644 src/main/java/com/kntro/reqsai/gateway/infrastructure/batch/IntegrationJobExecutionListener.java create mode 100644 src/main/java/com/kntro/reqsai/gateway/infrastructure/batch/IntegrationJobLauncherAdapter.java create mode 100644 src/main/java/com/kntro/reqsai/gateway/infrastructure/batch/IntegrationJobParameters.java create mode 100644 src/main/java/com/kntro/reqsai/gateway/infrastructure/batch/IntegrationJobProgressListener.java create mode 100644 src/main/java/com/kntro/reqsai/gateway/infrastructure/batch/JiraImportItemProcessor.java create mode 100644 src/main/java/com/kntro/reqsai/gateway/infrastructure/batch/JiraStoryPushItemProcessor.java create mode 100644 src/main/java/com/kntro/reqsai/gateway/infrastructure/batch/SyncItemOutcome.java diff --git a/src/main/java/com/kntro/reqsai/gateway/application/port/IntegrationJobLauncher.java b/src/main/java/com/kntro/reqsai/gateway/application/port/IntegrationJobLauncher.java new file mode 100644 index 00000000..901d2905 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/port/IntegrationJobLauncher.java @@ -0,0 +1,22 @@ +package com.kntro.reqsai.gateway.application.port; + +import org.jspecify.annotations.Nullable; + +import java.util.List; +import java.util.UUID; + +/** + * Port for launching the asynchronous execution of an integration sync job. The handlers create the + * durable {@code integration_sync_jobs} row first (the API's source of truth) and then hand the run + * to this port; the engine behind it is an infrastructure detail (currently Spring Batch — see + * {@code gateway.infrastructure.batch}). Implementations must return immediately (the endpoints + * answer {@code 202 Accepted}) and must propagate the caller's tenant to the execution. + */ +public interface IntegrationJobLauncher { + + /** Starts the Jira import run for an already-persisted RUNNING job row. */ + void launchImport(UUID jobId, UUID projectId, @Nullable List issueKeys); + + /** Starts the push-all run for an already-persisted RUNNING job row. */ + void launchPushAll(UUID jobId, UUID projectId); +} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/service/IntegrationSyncJobRunner.java b/src/main/java/com/kntro/reqsai/gateway/application/service/IntegrationSyncJobRunner.java deleted file mode 100644 index 92bd0aeb..00000000 --- a/src/main/java/com/kntro/reqsai/gateway/application/service/IntegrationSyncJobRunner.java +++ /dev/null @@ -1,178 +0,0 @@ -package com.kntro.reqsai.gateway.application.service; - -import com.kntro.reqsai.discovery.api.DiscoveryStoryReadPort; -import com.kntro.reqsai.discovery.api.StoryView; -import com.kntro.reqsai.gateway.application.notification.IntegrationJobProgressNotifier; -import com.kntro.reqsai.gateway.application.port.IntegrationProvider.RemoteIssue; -import com.kntro.reqsai.gateway.application.port.IntegrationSyncJobRepository; -import com.kntro.reqsai.gateway.application.port.ProjectIntegrationTargetRepository; -import com.kntro.reqsai.gateway.application.result.ImportStoryResult; -import com.kntro.reqsai.gateway.application.service.StoryPushService.PushContext; -import com.kntro.reqsai.gateway.domain.exception.IntegrationsExceptions; -import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJob; -import com.kntro.reqsai.gateway.domain.model.ProjectIntegrationTarget; -import com.kntro.reqsai.shared.domain.exception.DomainException; -import com.kntro.reqsai.shared.infrastructure.persistence.multitenancy.TenantContext; -import com.kntro.reqsai.shared.infrastructure.persistence.multitenancy.TenantContext.TenantSnapshot; -import lombok.extern.slf4j.Slf4j; -import org.jspecify.annotations.Nullable; -import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.core.task.TaskExecutor; -import org.springframework.stereotype.Component; - -import java.util.List; -import java.util.Set; -import java.util.UUID; - -/** - * Executes Jira import / push-all jobs on the shared async executor, off the request thread. The - * request thread only creates the job row and calls {@code dispatch*}; the worker then reuses the - * existing per-item mechanics ({@link JiraImportService} / {@link StoryPushService}), persisting the - * job counters after every item and mirroring each update to STOMP via - * {@link IntegrationJobProgressNotifier}. - * - *

              Tenant propagation — the tenant schema lives in the {@link TenantContext} - * ThreadLocal, which the async thread does not inherit. Following the established pattern for - * threads with no filter-managed context (see {@code TenantContext.runWith} and - * {@code TenantAwareModuleListener}), {@code dispatch*} captures a {@link TenantSnapshot} on the - * request thread and the worker restores it with {@link TenantContext#runWith} before any DB access, - * so every Hibernate session the job opens resolves the caller's tenant schema. - * - *

              Failure semantics: per-item failures increment {@code failed} and the run continues; a fatal - * error (target/connection resolution, Jira fetch) marks the job {@code FAILED} with a bounded - * message. Import duplicates count toward {@code processed} only and are summarized in the terminal - * message. - */ -@Component -@Slf4j -public class IntegrationSyncJobRunner { - - private final TaskExecutor executor; - private final IntegrationSyncJobRepository jobs; - private final ProjectIntegrationTargetRepository targets; - private final JiraImportService importService; - private final StoryPushService pushService; - private final DiscoveryStoryReadPort stories; - private final IntegrationJobProgressNotifier progress; - - public IntegrationSyncJobRunner( - @Qualifier("taskExecutor") TaskExecutor executor, - IntegrationSyncJobRepository jobs, - ProjectIntegrationTargetRepository targets, - JiraImportService importService, - StoryPushService pushService, - DiscoveryStoryReadPort stories, - IntegrationJobProgressNotifier progress) { - this.executor = executor; - this.jobs = jobs; - this.targets = targets; - this.importService = importService; - this.pushService = pushService; - this.stories = stories; - this.progress = progress; - } - - /** Dispatches an import run, capturing the caller's tenant for the worker thread. */ - public void dispatchImport(UUID jobId, @Nullable List issueKeys) { - TenantSnapshot tenant = TenantContext.capture(); - executor.execute(() -> TenantContext.runWith(tenant, () -> runImport(jobId, issueKeys))); - } - - /** Dispatches a push-all run, capturing the caller's tenant for the worker thread. */ - public void dispatchPushAll(UUID jobId) { - TenantSnapshot tenant = TenantContext.capture(); - executor.execute(() -> TenantContext.runWith(tenant, () -> runPushAll(jobId))); - } - - private void runImport(UUID jobId, @Nullable List issueKeys) { - IntegrationSyncJob job = jobs.findById(jobId).orElse(null); - if (job == null) { - log.error("Import job {} vanished before the worker started", jobId); - return; - } - try { - PushContext ctx = importService.contextFor(requireTarget(job.getProjectId())); - List issues = importService.fetchIssues(ctx); - - Set requested = issueKeys == null ? Set.of() : Set.copyOf(issueKeys); - List selected = issues.stream() - .filter(issue -> requested.isEmpty() || requested.contains(issue.issueKey())) - .toList(); - job.planTotal(selected.size()); - saveAndPublish(job); - - int duplicates = 0; - for (RemoteIssue issue : selected) { - // importIssue captures per-issue failures itself, so one bad issue never aborts the run. - ImportStoryResult result = importService.importIssue(job.getProjectId(), issue); - switch (result.status()) { - case IMPORTED -> job.recordSuccess(); - case FAILED -> job.recordFailure(); - case DUPLICATE -> { - job.recordSkipped(); - duplicates++; - } - } - saveAndPublish(job); - } - job.complete(duplicates > 0 ? duplicates + " duplicados omitidos" : null); - saveAndPublish(job); - log.info("Import job {} completed: {}/{} succeeded, {} failed, {} duplicates", - jobId, job.getSucceeded(), job.getTotal(), job.getFailed(), duplicates); - } catch (RuntimeException e) { - failJob(job, e); - } - } - - private void runPushAll(UUID jobId) { - IntegrationSyncJob job = jobs.findById(jobId).orElse(null); - if (job == null) { - log.error("Push-all job {} vanished before the worker started", jobId); - return; - } - try { - PushContext ctx = pushService.contextFor(requireTarget(job.getProjectId())); - List all = stories.listStories(job.getProjectId()); - job.planTotal(all.size()); - saveAndPublish(job); - - for (StoryView story : all) { - try { - pushService.push(ctx, story); - job.recordSuccess(); - } catch (DomainException e) { - // One story's provider failure must not abort the rest of the batch. - log.warn("Push failed for story {} [{}]", story.storyId(), e.error().code()); - job.recordFailure(); - } - saveAndPublish(job); - } - job.complete(null); - saveAndPublish(job); - log.info("Push-all job {} completed: {}/{} succeeded, {} failed", - jobId, job.getSucceeded(), job.getTotal(), job.getFailed()); - } catch (RuntimeException e) { - failJob(job, e); - } - } - - private ProjectIntegrationTarget requireTarget(UUID projectId) { - return targets.findByProjectId(projectId) - .orElseThrow(() -> IntegrationsExceptions.targetNotConfigured(projectId)); - } - - private void saveAndPublish(IntegrationSyncJob job) { - progress.publish(jobs.save(job)); - } - - /** Terminal fatal path: persist FAILED + message and publish, never letting the worker throw. */ - private void failJob(IntegrationSyncJob job, RuntimeException cause) { - log.error("Integration job {} failed fatally", job.getId(), cause); - try { - job.fail(cause.getMessage()); - saveAndPublish(job); - } catch (RuntimeException e) { - log.error("Could not persist failure of integration job {}", job.getId(), e); - } - } -} diff --git a/src/main/java/com/kntro/reqsai/gateway/infrastructure/batch/IntegrationBatchJobsConfiguration.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/batch/IntegrationBatchJobsConfiguration.java new file mode 100644 index 00000000..694c748a --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/batch/IntegrationBatchJobsConfiguration.java @@ -0,0 +1,218 @@ +package com.kntro.reqsai.gateway.infrastructure.batch; + +import com.kntro.reqsai.discovery.api.DiscoveryStoryReadPort; +import com.kntro.reqsai.discovery.api.StoryView; +import com.kntro.reqsai.gateway.application.notification.IntegrationJobProgressNotifier; +import com.kntro.reqsai.gateway.application.port.IntegrationProvider.RemoteIssue; +import com.kntro.reqsai.gateway.application.port.IntegrationSyncJobRepository; +import com.kntro.reqsai.gateway.application.port.ProjectIntegrationTargetRepository; +import com.kntro.reqsai.gateway.application.service.JiraImportService; +import com.kntro.reqsai.gateway.application.service.StoryPushService; +import com.kntro.reqsai.gateway.application.service.StoryPushService.PushContext; +import com.kntro.reqsai.gateway.domain.exception.IntegrationsExceptions; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJob; +import com.kntro.reqsai.gateway.domain.model.ProjectIntegrationTarget; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.job.Job; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.listener.ItemProcessListener; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.Step; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.infrastructure.item.support.ListItemReader; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +import java.util.List; +import java.util.Set; +import java.util.UUID; + +/** + * The two integration batch jobs (ADR-0023): {@code jiraImportJob} and {@code jiraPushAllJob}. Both + * follow the same topology — a single chunk-oriented step whose reader resolves the + * work list up front (planning the projection's {@code total}), whose processor delegates one item + * at a time to the existing application services, and whose writer is a no-op (the services persist + * their own side effects; the step only orchestrates). + * + *

              Key Spring Batch concepts as used here: + *

                + *
              • Chunk-oriented processing — items are read/processed one by one and the + * chunk transaction commits every {@value #CHUNK_SIZE} items, bounding both transaction size + * and lost progress on a crash.
              • + *
              • Fault tolerance — {@code faultTolerant().skip(Exception).skipLimit(MAX)} + * means one bad item is skipped (counted by the SkipListener), never fatal; that + * mirrors the per-item semantics of the old synchronous endpoints. A failure outside + * item processing (e.g. Jira unreachable in the reader) still fails the step and the job.
              • + *
              • {@code @StepScope} — readers/processors/progress listener are created per + * step execution and parameterized from {@code JobParameters} (late binding), because + * singleton step components could not carry per-run state like the project or job id.
              • + *
              + * + *

              Placement: this is infrastructure. The step components only drive the application + * layer ({@link JiraImportService}, {@link StoryPushService}) — swapping the engine again would + * touch this package and nothing else. + */ +@Configuration +public class IntegrationBatchJobsConfiguration { + + public static final String IMPORT_JOB_NAME = "jiraImportJob"; + public static final String PUSH_ALL_JOB_NAME = "jiraPushAllJob"; + + private static final int CHUNK_SIZE = 5; + + // ================================== + // IMPORT JOB + // ================================== + + @Bean + public Job jiraImportJob(JobRepository jobRepository, + @Qualifier("jiraImportStep") Step jiraImportStep, + IntegrationJobExecutionListener integrationJobExecutionListener) { + return new JobBuilder(IMPORT_JOB_NAME, jobRepository) + .listener(integrationJobExecutionListener) + .start(jiraImportStep) + .build(); + } + + @Bean + public Step jiraImportStep(JobRepository jobRepository, + PlatformTransactionManager transactionManager, + @Qualifier("jiraImportReader") ListItemReader jiraImportReader, + JiraImportItemProcessor jiraImportProcessor, + IntegrationJobProgressListener integrationJobProgressListener) { + return new StepBuilder("jiraImportStep", jobRepository) + .chunk(CHUNK_SIZE) + .reader(jiraImportReader) + .processor(jiraImportProcessor) + .writer(chunk -> { }) + .transactionManager(transactionManager) + .listener((ItemProcessListener) integrationJobProgressListener) + .faultTolerant() + .skip(Exception.class) + .skipLimit(Long.MAX_VALUE) + .skipListener(integrationJobProgressListener) + .build(); + } + + /** + * Resolves the import work list at step start: target → provider context → eligible Jira issues, + * optionally restricted to the requested keys. Also fixes the projection's {@code total} now that + * the real item count is known. A Jira failure here is fatal by design (nothing was processed + * yet) and fails the job. + */ + @Bean + @StepScope + public ListItemReader jiraImportReader( + @Value("#{jobParameters['" + IntegrationJobParameters.DOMAIN_JOB_ID + "']}") String domainJobId, + @Value("#{jobParameters['" + IntegrationJobParameters.PROJECT_ID + "']}") String projectId, + @Value("#{jobParameters['" + IntegrationJobParameters.ISSUE_KEYS + "']}") String issueKeysCsv, + ProjectIntegrationTargetRepository targets, + JiraImportService importService, + IntegrationSyncJobRepository jobs, + IntegrationJobProgressNotifier progress) { + UUID project = UUID.fromString(projectId); + PushContext ctx = importService.contextFor(requireTarget(targets, project)); + Set requested = IntegrationJobParameters.parseIssueKeys(issueKeysCsv); + List selected = importService.fetchIssues(ctx).stream() + .filter(issue -> requested.isEmpty() || requested.contains(issue.issueKey())) + .toList(); + planTotal(jobs, progress, domainJobId, selected.size()); + return new ListItemReader<>(selected); + } + + @Bean + @StepScope + public JiraImportItemProcessor jiraImportProcessor( + @Value("#{jobParameters['" + IntegrationJobParameters.PROJECT_ID + "']}") String projectId, + JiraImportService importService) { + return new JiraImportItemProcessor(importService, UUID.fromString(projectId)); + } + + // ================================== + // PUSH-ALL JOB + // ================================== + + @Bean + public Job jiraPushAllJob(JobRepository jobRepository, + @Qualifier("jiraPushAllStep") Step jiraPushAllStep, + IntegrationJobExecutionListener integrationJobExecutionListener) { + return new JobBuilder(PUSH_ALL_JOB_NAME, jobRepository) + .listener(integrationJobExecutionListener) + .start(jiraPushAllStep) + .build(); + } + + @Bean + public Step jiraPushAllStep(JobRepository jobRepository, + PlatformTransactionManager transactionManager, + @Qualifier("jiraPushAllReader") ListItemReader jiraPushAllReader, + JiraStoryPushItemProcessor jiraStoryPushProcessor, + IntegrationJobProgressListener integrationJobProgressListener) { + return new StepBuilder("jiraPushAllStep", jobRepository) + .chunk(CHUNK_SIZE) + .reader(jiraPushAllReader) + .processor(jiraStoryPushProcessor) + .writer(chunk -> { }) + .transactionManager(transactionManager) + .listener((ItemProcessListener) integrationJobProgressListener) + .faultTolerant() + .skip(Exception.class) + .skipLimit(Long.MAX_VALUE) + .skipListener(integrationJobProgressListener) + .build(); + } + + /** Resolves the push work list (every project story) and fixes the projection's {@code total}. */ + @Bean + @StepScope + public ListItemReader jiraPushAllReader( + @Value("#{jobParameters['" + IntegrationJobParameters.DOMAIN_JOB_ID + "']}") String domainJobId, + @Value("#{jobParameters['" + IntegrationJobParameters.PROJECT_ID + "']}") String projectId, + DiscoveryStoryReadPort stories, + IntegrationSyncJobRepository jobs, + IntegrationJobProgressNotifier progress) { + List all = stories.listStories(UUID.fromString(projectId)); + planTotal(jobs, progress, domainJobId, all.size()); + return new ListItemReader<>(all); + } + + @Bean + @StepScope + public JiraStoryPushItemProcessor jiraStoryPushProcessor( + @Value("#{jobParameters['" + IntegrationJobParameters.PROJECT_ID + "']}") String projectId, + ProjectIntegrationTargetRepository targets, + StoryPushService pushService) { + UUID project = UUID.fromString(projectId); + return new JiraStoryPushItemProcessor(pushService, + pushService.contextFor(requireTarget(targets, project))); + } + + // ================================== + // SHARED STEP COMPONENTS + // ================================== + + @Bean + @StepScope + public IntegrationJobProgressListener integrationJobProgressListener( + @Value("#{jobParameters['" + IntegrationJobParameters.DOMAIN_JOB_ID + "']}") String domainJobId, + IntegrationSyncJobRepository jobs, + IntegrationJobProgressNotifier progress) { + return new IntegrationJobProgressListener(UUID.fromString(domainJobId), jobs, progress); + } + + private static ProjectIntegrationTarget requireTarget(ProjectIntegrationTargetRepository targets, UUID projectId) { + return targets.findByProjectId(projectId) + .orElseThrow(() -> IntegrationsExceptions.targetNotConfigured(projectId)); + } + + private static void planTotal(IntegrationSyncJobRepository jobs, IntegrationJobProgressNotifier progress, + String domainJobId, int total) { + jobs.findById(UUID.fromString(domainJobId)).filter(IntegrationSyncJob::isRunning).ifPresent(job -> { + job.planTotal(total); + progress.publish(jobs.save(job)); + }); + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/infrastructure/batch/IntegrationJobExecutionListener.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/batch/IntegrationJobExecutionListener.java new file mode 100644 index 00000000..fe06a632 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/batch/IntegrationJobExecutionListener.java @@ -0,0 +1,92 @@ +package com.kntro.reqsai.gateway.infrastructure.batch; + +import com.kntro.reqsai.gateway.application.notification.IntegrationJobProgressNotifier; +import com.kntro.reqsai.gateway.application.port.IntegrationSyncJobRepository; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJob; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJobType; +import com.kntro.reqsai.shared.infrastructure.persistence.multitenancy.TenantContext; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.BatchStatus; +import org.springframework.batch.core.job.JobExecution; +import org.springframework.batch.core.listener.JobExecutionListener; +import org.springframework.stereotype.Component; + +import java.util.UUID; + +/** + * Frames every integration batch execution with the two cross-cutting concerns the engine cannot + * know about: + * + *

                + *
              1. Tenant restoration — the batch job runs on an executor thread with no + * filter-managed {@link TenantContext}. {@code beforeJob} restores the tenant/schema captured + * into the job parameters at launch time (the same snapshot-then-restore pattern used by + * {@code TenantAwareModuleListener} for async event consumers); {@code afterJob} clears it in a + * {@code finally} so the pooled thread never leaks a schema. The whole execution — listeners, + * step, readers, processors — runs on this one thread, so every Hibernate session it opens + * resolves the caller's tenant schema.
              2. + *
              3. Terminal projection state — {@code afterJob} runs whether the execution + * COMPLETED or FAILED, and is where the domain-facing {@code integration_sync_jobs} row gets + * its terminal status, {@code finished_at} and message (fatal-error summary, or the + * "N duplicados omitidos" note for imports), followed by the final STOMP publish.
              4. + *
              + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class IntegrationJobExecutionListener implements JobExecutionListener { + + private final IntegrationSyncJobRepository jobs; + private final IntegrationJobProgressNotifier progress; + + @Override + public void beforeJob(JobExecution jobExecution) { + String tenantId = jobExecution.getJobParameters().getString(IntegrationJobParameters.TENANT_ID); + String tenantSchema = jobExecution.getJobParameters().getString(IntegrationJobParameters.TENANT_SCHEMA); + TenantContext.setCurrentTenant(tenantId != null ? tenantId : TenantContext.DEFAULT_SCHEMA); + TenantContext.setCurrentSchema(tenantSchema != null ? tenantSchema : TenantContext.DEFAULT_SCHEMA); + log.debug("Integration batch job {} running for tenant schema {}", + jobExecution.getJobInstance().getJobName(), tenantSchema); + } + + @Override + public void afterJob(JobExecution jobExecution) { + try { + String domainJobId = jobExecution.getJobParameters().getString(IntegrationJobParameters.DOMAIN_JOB_ID); + IntegrationSyncJob job = domainJobId == null + ? null + : jobs.findById(UUID.fromString(domainJobId)).orElse(null); + if (job == null || !job.isRunning()) { + log.warn("No RUNNING projection row to finalize for batch execution {}", jobExecution.getId()); + return; + } + if (jobExecution.getStatus() == BatchStatus.COMPLETED) { + job.complete(completionMessage(job)); + } else { + job.fail(failureMessage(jobExecution)); + } + progress.publish(jobs.save(job)); + } finally { + TenantContext.clear(); + } + } + + /** Imports report skipped duplicates; other jobs complete silently. */ + private static String completionMessage(IntegrationSyncJob job) { + int duplicates = job.getProcessed() - job.getSucceeded() - job.getFailed(); + if (job.getJobType() == IntegrationSyncJobType.IMPORT && duplicates > 0) { + return duplicates + " duplicados omitidos"; + } + return null; + } + + /** First failure message of the execution (e.g. Jira unreachable while fetching), token-free. */ + private static String failureMessage(JobExecution jobExecution) { + return jobExecution.getAllFailureExceptions().stream() + .map(Throwable::getMessage) + .filter(m -> m != null && !m.isBlank()) + .findFirst() + .orElse("Job failed with status " + jobExecution.getStatus()); + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/infrastructure/batch/IntegrationJobLauncherAdapter.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/batch/IntegrationJobLauncherAdapter.java new file mode 100644 index 00000000..019c6dcb --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/batch/IntegrationJobLauncherAdapter.java @@ -0,0 +1,90 @@ +package com.kntro.reqsai.gateway.infrastructure.batch; + +import com.kntro.reqsai.gateway.application.notification.IntegrationJobProgressNotifier; +import com.kntro.reqsai.gateway.application.port.IntegrationJobLauncher; +import com.kntro.reqsai.gateway.application.port.IntegrationSyncJobRepository; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJob; +import com.kntro.reqsai.shared.infrastructure.persistence.multitenancy.TenantContext; +import com.kntro.reqsai.shared.infrastructure.persistence.multitenancy.TenantContext.TenantSnapshot; +import lombok.extern.slf4j.Slf4j; +import org.jspecify.annotations.Nullable; +import org.springframework.batch.core.job.Job; +import org.springframework.batch.core.job.parameters.JobParametersBuilder; +import org.springframework.batch.core.launch.JobOperator; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.UUID; + +/** + * Spring Batch adapter for the {@link IntegrationJobLauncher} port. Captures the caller's tenant on + * the request thread ({@link TenantContext#capture()}) into job parameters — the batch + * equivalent of the snapshot-then-restore pattern used by the app's other async paths — and starts + * the job through the {@code JobOperator}, whose task executor makes {@code start} return as soon as + * the execution is registered (that is what backs the {@code 202 Accepted} contract). + * + *

              The {@code domainJobId} is the only identifying parameter, so each API launch + * is a brand-new JobInstance. If the launch itself fails (no executor thread, metadata store down), + * the projection row is failed immediately so no client is left watching a phantom RUNNING job. + */ +@Component +@Slf4j +public class IntegrationJobLauncherAdapter implements IntegrationJobLauncher { + + private final JobOperator jobOperator; + private final Job jiraImportJob; + private final Job jiraPushAllJob; + private final IntegrationSyncJobRepository jobs; + private final IntegrationJobProgressNotifier progress; + + public IntegrationJobLauncherAdapter( + JobOperator jobOperator, + @Qualifier("jiraImportJob") Job jiraImportJob, + @Qualifier("jiraPushAllJob") Job jiraPushAllJob, + IntegrationSyncJobRepository jobs, + IntegrationJobProgressNotifier progress) { + this.jobOperator = jobOperator; + this.jiraImportJob = jiraImportJob; + this.jiraPushAllJob = jiraPushAllJob; + this.jobs = jobs; + this.progress = progress; + } + + @Override + public void launchImport(UUID jobId, UUID projectId, @Nullable List issueKeys) { + launch(jiraImportJob, jobId, projectId, IntegrationJobParameters.joinIssueKeys(issueKeys)); + } + + @Override + public void launchPushAll(UUID jobId, UUID projectId) { + launch(jiraPushAllJob, jobId, projectId, null); + } + + private void launch(Job batchJob, UUID jobId, UUID projectId, @Nullable String issueKeysCsv) { + TenantSnapshot tenant = TenantContext.capture(); + JobParametersBuilder params = new JobParametersBuilder() + .addString(IntegrationJobParameters.DOMAIN_JOB_ID, jobId.toString(), true) + .addString(IntegrationJobParameters.PROJECT_ID, projectId.toString(), false) + .addString(IntegrationJobParameters.TENANT_ID, tenant.tenantId(), false) + .addString(IntegrationJobParameters.TENANT_SCHEMA, tenant.tenantSchema(), false); + if (issueKeysCsv != null) { + params.addString(IntegrationJobParameters.ISSUE_KEYS, issueKeysCsv, false); + } + try { + jobOperator.start(batchJob, params.toJobParameters()); + } catch (Exception e) { + failUnlaunched(jobId, e); + } + } + + /** The execution never started: fail the projection so the UI is not stuck on RUNNING. */ + private void failUnlaunched(UUID jobId, Exception cause) { + log.error("Could not launch integration batch job {}", jobId, cause); + jobs.findById(jobId).filter(IntegrationSyncJob::isRunning).ifPresent(job -> { + job.fail("The background job could not be launched"); + progress.publish(jobs.save(job)); + }); + throw new IllegalStateException("Could not launch integration batch job " + jobId, cause); + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/infrastructure/batch/IntegrationJobParameters.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/batch/IntegrationJobParameters.java new file mode 100644 index 00000000..2e124b9e --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/batch/IntegrationJobParameters.java @@ -0,0 +1,51 @@ +package com.kntro.reqsai.gateway.infrastructure.batch; + +import java.util.LinkedHashSet; +import java.util.Set; + +/** + * Job-parameter keys shared by the integration batch jobs. Spring Batch derives the + * JobInstance identity from the job name plus the identifying parameters — + * here only {@link #DOMAIN_JOB_ID} (the {@code integration_sync_jobs} UUID) is identifying, so every + * API-triggered run is a fresh JobInstance with exactly one JobExecution, and the batch metadata + * links 1:1 back to the domain row. The remaining keys are non-identifying context the execution + * needs: the tenant coordinates to restore ({@link #TENANT_ID}/{@link #TENANT_SCHEMA}), the project, + * and the optional issue-key selection. + */ +public final class IntegrationJobParameters { + + /** Identifying: the {@code integration_sync_jobs} row this execution reports into. */ + public static final String DOMAIN_JOB_ID = "domainJobId"; + + public static final String PROJECT_ID = "projectId"; + public static final String TENANT_ID = "tenantId"; + public static final String TENANT_SCHEMA = "tenantSchema"; + + /** Comma-joined Jira issue keys to import; absent/blank means all eligible issues. */ + public static final String ISSUE_KEYS = "issueKeys"; + + private IntegrationJobParameters() { + throw new UnsupportedOperationException("Utility class - do not instantiate"); + } + + /** Parses the comma-joined {@link #ISSUE_KEYS} value; empty set means "no restriction". */ + public static Set parseIssueKeys(String issueKeysCsv) { + Set keys = new LinkedHashSet<>(); + if (issueKeysCsv != null && !issueKeysCsv.isBlank()) { + for (String key : issueKeysCsv.split(",")) { + if (!key.isBlank()) { + keys.add(key.trim()); + } + } + } + return keys; + } + + /** Joins issue keys for the {@link #ISSUE_KEYS} parameter; {@code null} when unrestricted. */ + public static String joinIssueKeys(java.util.List issueKeys) { + if (issueKeys == null || issueKeys.isEmpty()) { + return null; + } + return String.join(",", issueKeys); + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/infrastructure/batch/IntegrationJobProgressListener.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/batch/IntegrationJobProgressListener.java new file mode 100644 index 00000000..88660506 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/batch/IntegrationJobProgressListener.java @@ -0,0 +1,68 @@ +package com.kntro.reqsai.gateway.infrastructure.batch; + +import com.kntro.reqsai.gateway.application.notification.IntegrationJobProgressNotifier; +import com.kntro.reqsai.gateway.application.port.IntegrationSyncJobRepository; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJob; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.listener.ItemProcessListener; +import org.springframework.batch.core.listener.SkipListener; + +import java.util.UUID; +import java.util.function.Consumer; + +/** + * Per-item progress bridge from the batch step to the domain projection: after each processed item + * it updates the {@code integration_sync_jobs} counters and publishes the fresh snapshot to the + * project's STOMP topic. Registered twice on the step: + * + *

                + *
              • {@link ItemProcessListener#afterProcess} — normal path; the processor reports the outcome + * ({@code SUCCEEDED}/{@code SKIPPED}/{@code FAILED}) without throwing.
              • + *
              • {@link SkipListener#onSkipInProcess} — fault-tolerant path; the processor threw, the step's + * skip policy swallowed the exception, and the item counts as a failure.
              • + *
              + * + *

              Instantiated {@code @StepScope} (one instance per step execution) because the target row id + * comes from the {@code domainJobId} job parameter. Counter writes join the surrounding chunk + * transaction — durable at each chunk boundary — while STOMP frames go out immediately; if a chunk + * rolls back for item-by-item skip rescanning, the counters are re-derived from the reverted row, so + * the projection stays consistent (a transient duplicate STOMP frame is harmless for a progress + * banner). + */ +@RequiredArgsConstructor +@Slf4j +public class IntegrationJobProgressListener + implements ItemProcessListener, SkipListener { + + private final UUID domainJobId; + private final IntegrationSyncJobRepository jobs; + private final IntegrationJobProgressNotifier progress; + + @Override + public void afterProcess(Object item, SyncItemOutcome outcome) { + if (outcome == null) { + return; // item filtered out by the processor; nothing to count + } + record(job -> { + switch (outcome) { + case SUCCEEDED -> job.recordSuccess(); + case SKIPPED -> job.recordSkipped(); + case FAILED -> job.recordFailure(); + } + }); + } + + @Override + public void onSkipInProcess(Object item, Throwable t) { + log.warn("Integration job {} skipped one item: {}", domainJobId, t.getMessage()); + record(IntegrationSyncJob::recordFailure); + } + + private void record(Consumer update) { + jobs.findById(domainJobId).filter(IntegrationSyncJob::isRunning).ifPresent(job -> { + update.accept(job); + progress.publish(jobs.save(job)); + }); + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/infrastructure/batch/JiraImportItemProcessor.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/batch/JiraImportItemProcessor.java new file mode 100644 index 00000000..a78c27e4 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/batch/JiraImportItemProcessor.java @@ -0,0 +1,33 @@ +package com.kntro.reqsai.gateway.infrastructure.batch; + +import com.kntro.reqsai.gateway.application.port.IntegrationProvider.RemoteIssue; +import com.kntro.reqsai.gateway.application.result.ImportStoryResult; +import com.kntro.reqsai.gateway.application.service.JiraImportService; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.infrastructure.item.ItemProcessor; + +import java.util.UUID; + +/** + * Chunk-step processor for the import job: one Jira issue in, one {@link SyncItemOutcome} out, + * delegating to the existing {@link JiraImportService#importIssue} (LLM mapping + dedup, owned by + * the application layer — the batch step is only the driver). The service captures per-issue + * failures itself and reports them as a {@code FAILED} result, so this processor normally never + * throws; anything unexpected that does escape is handled by the step's skip policy. + */ +@RequiredArgsConstructor +public class JiraImportItemProcessor implements ItemProcessor { + + private final JiraImportService importService; + private final UUID projectId; + + @Override + public SyncItemOutcome process(RemoteIssue issue) { + ImportStoryResult result = importService.importIssue(projectId, issue); + return switch (result.status()) { + case IMPORTED -> SyncItemOutcome.SUCCEEDED; + case DUPLICATE -> SyncItemOutcome.SKIPPED; + case FAILED -> SyncItemOutcome.FAILED; + }; + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/infrastructure/batch/JiraStoryPushItemProcessor.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/batch/JiraStoryPushItemProcessor.java new file mode 100644 index 00000000..27acd9dc --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/batch/JiraStoryPushItemProcessor.java @@ -0,0 +1,27 @@ +package com.kntro.reqsai.gateway.infrastructure.batch; + +import com.kntro.reqsai.discovery.api.StoryView; +import com.kntro.reqsai.gateway.application.service.StoryPushService; +import com.kntro.reqsai.gateway.application.service.StoryPushService.PushContext; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.infrastructure.item.ItemProcessor; + +/** + * Chunk-step processor for the push-all job: one story in, pushed to the tracker via the existing + * {@link StoryPushService} with the context resolved once per step execution. A provider failure + * throws on purpose: the step's fault-tolerant skip policy swallows it, the SkipListener + * counts it as a failed item, and the batch moves on — the same per-item semantics the old + * synchronous push-all endpoint had. + */ +@RequiredArgsConstructor +public class JiraStoryPushItemProcessor implements ItemProcessor { + + private final StoryPushService pushService; + private final PushContext context; + + @Override + public SyncItemOutcome process(StoryView story) { + pushService.push(context, story); + return SyncItemOutcome.SUCCEEDED; + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/infrastructure/batch/SyncItemOutcome.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/batch/SyncItemOutcome.java new file mode 100644 index 00000000..3ade74c5 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/batch/SyncItemOutcome.java @@ -0,0 +1,13 @@ +package com.kntro.reqsai.gateway.infrastructure.batch; + +/** + * Per-item result flowing from the batch item processors to the progress listener. {@code SKIPPED} + * is an import duplicate (processed but neither succeeded nor failed); {@code FAILED} is a per-item + * failure the processor captured itself (the run continues). Items whose processor throws + * instead are routed through the step's skip policy and land in the SkipListener, not here. + */ +public enum SyncItemOutcome { + SUCCEEDED, + SKIPPED, + FAILED +} From 86035d161c561e6fce1e2aa272000de9a108f2dd Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Thu, 9 Jul 2026 22:05:10 -0500 Subject: [PATCH 59/72] feat(gateway): switch jira import and push-all to async 202 job endpoints POST import / push-all now validate the target, persist a RUNNING integration_sync_jobs row (409 INTEGRATION_JOB_ALREADY_RUNNING on a concurrent start) and return 202 with the IntegrationJobResponse snapshot; new GET jobs (?active=true) and GET jobs/{jobId} endpoints (INTEGRATION_READ) serve reload recovery. Removes the synchronous batch result DTOs. --- .../GetIntegrationJobQueryHandler.java | 27 +++++ .../ImportJiraStoriesCommandHandler.java | 52 ++++----- .../ListIntegrationJobsQueryHandler.java | 26 +++++ .../handler/PushAllStoriesCommandHandler.java | 56 ++++------ .../query/GetIntegrationJobQuery.java | 6 + .../query/ListIntegrationJobsQuery.java | 10 ++ .../application/result/BatchImportResult.java | 18 --- .../application/result/BatchPushResult.java | 12 -- .../ProjectIntegrationControllerImpl.java | 43 +++++-- .../rest/dto/response/BatchPushResponse.java | 9 -- .../dto/response/IntegrationJobResponse.java | 28 +++++ .../rest/dto/response/JiraImportResponse.java | 19 ---- .../response/IntegrationResponseMapper.java | 37 +++--- .../swagger/ProjectIntegrationController.java | 72 +++++++++--- .../ImportJiraStoriesCommandHandlerTest.java | 97 ++++++++-------- .../PushAllStoriesCommandHandlerTest.java | 105 ++++++++---------- .../rest/JiraImportIntegrationTest.java | 89 ++++++++++----- .../JiraIntegrationPushIntegrationTest.java | 43 ++++++- 18 files changed, 443 insertions(+), 306 deletions(-) create mode 100644 src/main/java/com/kntro/reqsai/gateway/application/handler/GetIntegrationJobQueryHandler.java create mode 100644 src/main/java/com/kntro/reqsai/gateway/application/handler/ListIntegrationJobsQueryHandler.java create mode 100644 src/main/java/com/kntro/reqsai/gateway/application/query/GetIntegrationJobQuery.java create mode 100644 src/main/java/com/kntro/reqsai/gateway/application/query/ListIntegrationJobsQuery.java delete mode 100644 src/main/java/com/kntro/reqsai/gateway/application/result/BatchImportResult.java delete mode 100644 src/main/java/com/kntro/reqsai/gateway/application/result/BatchPushResult.java delete mode 100644 src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/BatchPushResponse.java create mode 100644 src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/IntegrationJobResponse.java delete mode 100644 src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/JiraImportResponse.java diff --git a/src/main/java/com/kntro/reqsai/gateway/application/handler/GetIntegrationJobQueryHandler.java b/src/main/java/com/kntro/reqsai/gateway/application/handler/GetIntegrationJobQueryHandler.java new file mode 100644 index 00000000..a098c338 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/handler/GetIntegrationJobQueryHandler.java @@ -0,0 +1,27 @@ +package com.kntro.reqsai.gateway.application.handler; + +import com.kntro.reqsai.gateway.application.port.IntegrationSyncJobRepository; +import com.kntro.reqsai.gateway.application.query.GetIntegrationJobQuery; +import com.kntro.reqsai.gateway.domain.exception.IntegrationsExceptions; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJob; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * Fetches one integration sync job, scoped to the project in the path: a job id belonging to a + * different project 404s ({@code INTEGRATION_JOB_NOT_FOUND}) just like a nonexistent one. + */ +@Component +@RequiredArgsConstructor +public class GetIntegrationJobQueryHandler { + + private final IntegrationSyncJobRepository jobs; + + @Transactional(readOnly = true) + public IntegrationSyncJob handle(GetIntegrationJobQuery query) { + return jobs.findById(query.jobId()) + .filter(job -> job.getProjectId().equals(query.projectId())) + .orElseThrow(() -> IntegrationsExceptions.jobNotFound(query.jobId())); + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/handler/ImportJiraStoriesCommandHandler.java b/src/main/java/com/kntro/reqsai/gateway/application/handler/ImportJiraStoriesCommandHandler.java index c69e0dec..99590011 100644 --- a/src/main/java/com/kntro/reqsai/gateway/application/handler/ImportJiraStoriesCommandHandler.java +++ b/src/main/java/com/kntro/reqsai/gateway/application/handler/ImportJiraStoriesCommandHandler.java @@ -1,51 +1,41 @@ package com.kntro.reqsai.gateway.application.handler; import com.kntro.reqsai.gateway.application.command.ImportJiraStoriesCommand; -import com.kntro.reqsai.gateway.application.port.IntegrationProvider.RemoteIssue; +import com.kntro.reqsai.gateway.application.port.IntegrationJobLauncher; import com.kntro.reqsai.gateway.application.port.ProjectIntegrationTargetRepository; -import com.kntro.reqsai.gateway.application.result.BatchImportResult; -import com.kntro.reqsai.gateway.application.result.ImportStoryResult; -import com.kntro.reqsai.gateway.application.service.JiraImportService; -import com.kntro.reqsai.gateway.application.service.StoryPushService.PushContext; +import com.kntro.reqsai.gateway.application.service.IntegrationSyncJobStarter; import com.kntro.reqsai.gateway.domain.exception.IntegrationsExceptions; -import com.kntro.reqsai.gateway.domain.model.ProjectIntegrationTarget; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJob; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJobType; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -import java.util.ArrayList; -import java.util.List; -import java.util.Set; /** - * Pulls Jira issues from the project's configured target and creates them as user stories via the discovery - * write port (which owns the LLM mapping + dedup). 409 ({@code INTEGRATION_TARGET_NOT_CONFIGURED}) when no - * target exists. Per-issue failures are captured without aborting the batch; duplicates are counted as - * {@code skipped}. When {@code issueKeys} is null/empty, all eligible issues are imported. + * Accepts a Jira import request as an asynchronous background job: validates the + * target exists (409 {@code INTEGRATION_TARGET_NOT_CONFIGURED}), persists a RUNNING + * {@code integration_sync_jobs} row (409 {@code INTEGRATION_JOB_ALREADY_RUNNING} when one is + * already running), hands execution to the {@link IntegrationJobLauncher} and returns the job + * snapshot for the 202 response. Progress streams on + * {@code /topic/projects/{projectId}/integration-jobs} and is queryable via the job endpoints. + * Deliberately not {@code @Transactional}: the job row must be committed (and visible to the async + * worker and to reload queries) before the launch. */ @Component @RequiredArgsConstructor public class ImportJiraStoriesCommandHandler { private final ProjectIntegrationTargetRepository targets; - private final JiraImportService importService; + private final IntegrationSyncJobStarter starter; + private final IntegrationJobLauncher launcher; - @Transactional - public BatchImportResult handle(ImportJiraStoriesCommand command) { - ProjectIntegrationTarget target = targets.findByProjectId(command.projectId()) + public IntegrationSyncJob handle(ImportJiraStoriesCommand command) { + targets.findByProjectId(command.projectId()) .orElseThrow(() -> IntegrationsExceptions.targetNotConfigured(command.projectId())); - PushContext ctx = importService.contextFor(target); - List issues = importService.fetchIssues(ctx); - - Set requested = command.issueKeys() == null ? Set.of() : Set.copyOf(command.issueKeys()); - List results = new ArrayList<>(); - for (RemoteIssue issue : issues) { - if (!requested.isEmpty() && !requested.contains(issue.issueKey())) { - continue; - } - results.add(importService.importIssue(command.projectId(), issue)); - } - return BatchImportResult.of(results); + int knownTotal = command.issueKeys() == null ? 0 : command.issueKeys().size(); + IntegrationSyncJob job = starter.start( + command.projectId(), IntegrationSyncJobType.IMPORT, knownTotal, command.requestedBy()); + launcher.launchImport(job.getId(), command.projectId(), command.issueKeys()); + return job; } } diff --git a/src/main/java/com/kntro/reqsai/gateway/application/handler/ListIntegrationJobsQueryHandler.java b/src/main/java/com/kntro/reqsai/gateway/application/handler/ListIntegrationJobsQueryHandler.java new file mode 100644 index 00000000..a0e422f6 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/handler/ListIntegrationJobsQueryHandler.java @@ -0,0 +1,26 @@ +package com.kntro.reqsai.gateway.application.handler; + +import com.kntro.reqsai.gateway.application.port.IntegrationSyncJobRepository; +import com.kntro.reqsai.gateway.application.query.ListIntegrationJobsQuery; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJob; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * Lists a project's integration sync jobs: RUNNING only when {@code activeOnly} (what a reloaded + * page asks first to re-attach its progress banner), else the most recent ~10 of any status. + */ +@Component +@RequiredArgsConstructor +public class ListIntegrationJobsQueryHandler { + + private final IntegrationSyncJobRepository jobs; + + @Transactional(readOnly = true) + public List handle(ListIntegrationJobsQuery query) { + return query.activeOnly() ? jobs.findRunning(query.projectId()) : jobs.findRecent(query.projectId()); + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/handler/PushAllStoriesCommandHandler.java b/src/main/java/com/kntro/reqsai/gateway/application/handler/PushAllStoriesCommandHandler.java index 5042b395..3941099f 100644 --- a/src/main/java/com/kntro/reqsai/gateway/application/handler/PushAllStoriesCommandHandler.java +++ b/src/main/java/com/kntro/reqsai/gateway/application/handler/PushAllStoriesCommandHandler.java @@ -1,58 +1,42 @@ package com.kntro.reqsai.gateway.application.handler; import com.kntro.reqsai.discovery.api.DiscoveryStoryReadPort; -import com.kntro.reqsai.discovery.api.StoryView; import com.kntro.reqsai.gateway.application.command.PushAllStoriesCommand; -import com.kntro.reqsai.gateway.application.port.IntegrationProvider.PushedIssue; +import com.kntro.reqsai.gateway.application.port.IntegrationJobLauncher; import com.kntro.reqsai.gateway.application.port.ProjectIntegrationTargetRepository; -import com.kntro.reqsai.gateway.application.result.BatchPushResult; -import com.kntro.reqsai.gateway.application.result.StoryPushResult; -import com.kntro.reqsai.gateway.application.service.StoryPushService; -import com.kntro.reqsai.gateway.application.service.StoryPushService.PushContext; +import com.kntro.reqsai.gateway.application.service.IntegrationSyncJobStarter; import com.kntro.reqsai.gateway.domain.exception.IntegrationsExceptions; -import com.kntro.reqsai.gateway.domain.model.ProjectIntegrationTarget; -import com.kntro.reqsai.shared.domain.exception.DomainException; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJob; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJobType; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -import java.util.ArrayList; -import java.util.List; /** - * Pushes every story of a project to its Jira target, capturing per-story failures without - * aborting the batch: a failed push records the error code and the loop continues. 409 - * ({@code INTEGRATION_TARGET_NOT_CONFIGURED}) when no target exists. + * Accepts a push-all request as an asynchronous background job: validates the + * target exists (409 {@code INTEGRATION_TARGET_NOT_CONFIGURED}), persists a RUNNING + * {@code integration_sync_jobs} row (409 {@code INTEGRATION_JOB_ALREADY_RUNNING} when one is + * already running), hands execution to the {@link IntegrationJobLauncher} and returns the job + * snapshot for the 202 response. The known story count seeds {@code total} immediately so the + * progress banner can render a meaningful bar from the first frame. Deliberately not + * {@code @Transactional}: the job row must be committed before the launch. */ @Component @RequiredArgsConstructor -@Slf4j public class PushAllStoriesCommandHandler { private final ProjectIntegrationTargetRepository targets; private final DiscoveryStoryReadPort stories; - private final StoryPushService pushService; + private final IntegrationSyncJobStarter starter; + private final IntegrationJobLauncher launcher; - @Transactional(readOnly = true) - public BatchPushResult handle(PushAllStoriesCommand command) { - ProjectIntegrationTarget target = targets.findByProjectId(command.projectId()) + public IntegrationSyncJob handle(PushAllStoriesCommand command) { + targets.findByProjectId(command.projectId()) .orElseThrow(() -> IntegrationsExceptions.targetNotConfigured(command.projectId())); - PushContext ctx = pushService.contextFor(target); - List all = stories.listStories(command.projectId()); - - List results = new ArrayList<>(all.size()); - for (StoryView story : all) { - try { - PushedIssue issue = pushService.push(ctx, story); - results.add(StoryPushResult.success(story.storyId(), issue.issueKey(), issue.issueUrl())); - } catch (DomainException e) { - // Infrastructure/domain failure on one story must not abort the rest of the batch. - log.warn("Push failed for story {} [{}]", story.storyId(), e.error().code()); - results.add(StoryPushResult.failure(story.storyId(), e.error().code())); - } - } - return BatchPushResult.of(results); + int knownTotal = stories.listStories(command.projectId()).size(); + IntegrationSyncJob job = starter.start( + command.projectId(), IntegrationSyncJobType.PUSH_ALL, knownTotal, command.requestedBy()); + launcher.launchPushAll(job.getId(), command.projectId()); + return job; } } diff --git a/src/main/java/com/kntro/reqsai/gateway/application/query/GetIntegrationJobQuery.java b/src/main/java/com/kntro/reqsai/gateway/application/query/GetIntegrationJobQuery.java new file mode 100644 index 00000000..cfccc4e5 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/query/GetIntegrationJobQuery.java @@ -0,0 +1,6 @@ +package com.kntro.reqsai.gateway.application.query; + +import java.util.UUID; + +/** Fetches one integration sync job of a project by id (404 when absent or in another project). */ +public record GetIntegrationJobQuery(UUID projectId, UUID jobId, UUID requestedBy) {} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/query/ListIntegrationJobsQuery.java b/src/main/java/com/kntro/reqsai/gateway/application/query/ListIntegrationJobsQuery.java new file mode 100644 index 00000000..3849da3f --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/query/ListIntegrationJobsQuery.java @@ -0,0 +1,10 @@ +package com.kntro.reqsai.gateway.application.query; + +import java.util.UUID; + +/** + * Lists a project's integration sync jobs. {@code activeOnly} limits the result to RUNNING jobs + * (the reload-recovery path for the global progress banner); otherwise the most recent ~10 jobs of + * any status are returned, newest first. + */ +public record ListIntegrationJobsQuery(UUID projectId, boolean activeOnly, UUID requestedBy) {} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/result/BatchImportResult.java b/src/main/java/com/kntro/reqsai/gateway/application/result/BatchImportResult.java deleted file mode 100644 index cfc00325..00000000 --- a/src/main/java/com/kntro/reqsai/gateway/application/result/BatchImportResult.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.kntro.reqsai.gateway.application.result; - -import java.util.List; - -/** - * Aggregate result of a Jira import: per-issue {@link ImportStoryResult}s plus the counts required by the - * locked contract. {@code imported} counts created stories, {@code skipped} counts duplicates, and - * {@code failed} counts per-issue failures (which never abort the batch). - */ -public record BatchImportResult(int imported, int skipped, int failed, List results) { - - public static BatchImportResult of(List results) { - int imported = (int) results.stream().filter(r -> r.status() == ImportStoryResult.Status.IMPORTED).count(); - int skipped = (int) results.stream().filter(r -> r.status() == ImportStoryResult.Status.DUPLICATE).count(); - int failed = (int) results.stream().filter(r -> r.status() == ImportStoryResult.Status.FAILED).count(); - return new BatchImportResult(imported, skipped, failed, results); - } -} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/result/BatchPushResult.java b/src/main/java/com/kntro/reqsai/gateway/application/result/BatchPushResult.java deleted file mode 100644 index 1b1299c9..00000000 --- a/src/main/java/com/kntro/reqsai/gateway/application/result/BatchPushResult.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.kntro.reqsai.gateway.application.result; - -import java.util.List; - -/** Aggregate result of a push-all: per-story results plus pushed/failed counts. */ -public record BatchPushResult(List results, int pushed, int failed) { - - public static BatchPushResult of(List results) { - int pushed = (int) results.stream().filter(StoryPushResult::isSuccess).count(); - return new BatchPushResult(results, pushed, results.size() - pushed); - } -} diff --git a/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/controllers/ProjectIntegrationControllerImpl.java b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/controllers/ProjectIntegrationControllerImpl.java index 0b38cefa..66b59fcf 100644 --- a/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/controllers/ProjectIntegrationControllerImpl.java +++ b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/controllers/ProjectIntegrationControllerImpl.java @@ -5,19 +5,22 @@ import com.kntro.reqsai.gateway.application.command.PushAllStoriesCommand; import com.kntro.reqsai.gateway.application.command.PushStoryCommand; import com.kntro.reqsai.gateway.application.handler.DeleteProjectTargetCommandHandler; +import com.kntro.reqsai.gateway.application.handler.GetIntegrationJobQueryHandler; import com.kntro.reqsai.gateway.application.handler.GetProjectTargetQueryHandler; import com.kntro.reqsai.gateway.application.handler.ImportJiraStoriesCommandHandler; +import com.kntro.reqsai.gateway.application.handler.ListIntegrationJobsQueryHandler; import com.kntro.reqsai.gateway.application.handler.PreviewJiraImportQueryHandler; import com.kntro.reqsai.gateway.application.handler.PushAllStoriesCommandHandler; import com.kntro.reqsai.gateway.application.handler.PushStoryCommandHandler; import com.kntro.reqsai.gateway.application.handler.SaveProjectTargetCommandHandler; +import com.kntro.reqsai.gateway.application.query.GetIntegrationJobQuery; import com.kntro.reqsai.gateway.application.query.GetProjectTargetQuery; +import com.kntro.reqsai.gateway.application.query.ListIntegrationJobsQuery; import com.kntro.reqsai.gateway.application.query.PreviewJiraImportQuery; import com.kntro.reqsai.gateway.interfaces.rest.dto.request.ImportJiraStoriesRequest; import com.kntro.reqsai.gateway.interfaces.rest.dto.request.SaveProjectTargetRequest; -import com.kntro.reqsai.gateway.interfaces.rest.dto.response.BatchPushResponse; +import com.kntro.reqsai.gateway.interfaces.rest.dto.response.IntegrationJobResponse; import com.kntro.reqsai.gateway.interfaces.rest.dto.response.JiraImportPreviewResponse; -import com.kntro.reqsai.gateway.interfaces.rest.dto.response.JiraImportResponse; import com.kntro.reqsai.gateway.interfaces.rest.dto.response.JiraPushResultResponse; import com.kntro.reqsai.gateway.interfaces.rest.dto.response.ProjectJiraTargetResponse; import com.kntro.reqsai.gateway.interfaces.rest.mappers.request.IntegrationRequestMapper; @@ -34,8 +37,15 @@ /** * Project-level integration endpoints. Target read/write/delete are gated by project - * {@code INTEGRATION_WRITE}; story pushes by {@code INTEGRATION_SYNC}, via the tenant-bound + * {@code INTEGRATION_WRITE}; story pushes/imports by {@code INTEGRATION_SYNC}, via the tenant-bound * {@code @authz.projectPermission} variant (these routes carry no {@code orgId}). + * + *

              Import and push-all are asynchronous: they answer {@code 202 Accepted} with an + * {@link IntegrationJobResponse} snapshot; live progress streams on + * {@code /topic/projects/{projectId}/integration-jobs} and the job endpoints serve reload recovery. + * The single-story push and the import preview stay synchronous. Job reads are gated by + * {@code INTEGRATION_READ} (they expose progress state, not sync capability — consistent with the + * target GET). */ @RestController @RequiredArgsConstructor @@ -48,6 +58,8 @@ public class ProjectIntegrationControllerImpl implements ProjectIntegrationContr private final PushAllStoriesCommandHandler pushAllStories; private final PreviewJiraImportQueryHandler previewImport; private final ImportJiraStoriesCommandHandler importStories; + private final ListIntegrationJobsQueryHandler listJobs; + private final GetIntegrationJobQueryHandler getJob; @Override @PreAuthorize("@authz.projectPermission(#projectId, 'INTEGRATION_READ', authentication)") @@ -84,9 +96,9 @@ public ResponseEntity pushStory(UUID projectId, UUID sto @Override @PreAuthorize("@authz.projectPermission(#projectId, 'INTEGRATION_SYNC', authentication)") - public ResponseEntity pushAllStories(UUID projectId, Authentication authentication) { + public ResponseEntity pushAllStories(UUID projectId, Authentication authentication) { UUID requestedBy = UUID.fromString(authentication.getName()); - return ResponseEntity.ok(IntegrationResponseMapper.toResponse( + return ResponseEntity.accepted().body(IntegrationResponseMapper.toResponse( pushAllStories.handle(new PushAllStoriesCommand(projectId, requestedBy)))); } @@ -100,11 +112,28 @@ public ResponseEntity previewImport(UUID projectId, A @Override @PreAuthorize("@authz.projectPermission(#projectId, 'INTEGRATION_SYNC', authentication)") - public ResponseEntity importStories( + public ResponseEntity importStories( UUID projectId, ImportJiraStoriesRequest request, Authentication authentication) { UUID requestedBy = UUID.fromString(authentication.getName()); List issueKeys = request == null ? null : request.issueKeys(); - return ResponseEntity.ok(IntegrationResponseMapper.toResponse( + return ResponseEntity.accepted().body(IntegrationResponseMapper.toResponse( importStories.handle(new ImportJiraStoriesCommand(projectId, issueKeys, requestedBy)))); } + + @Override + @PreAuthorize("@authz.projectPermission(#projectId, 'INTEGRATION_READ', authentication)") + public ResponseEntity> listJobs( + UUID projectId, boolean active, Authentication authentication) { + UUID requestedBy = UUID.fromString(authentication.getName()); + return ResponseEntity.ok(listJobs.handle(new ListIntegrationJobsQuery(projectId, active, requestedBy)) + .stream().map(IntegrationResponseMapper::toResponse).toList()); + } + + @Override + @PreAuthorize("@authz.projectPermission(#projectId, 'INTEGRATION_READ', authentication)") + public ResponseEntity getJob(UUID projectId, UUID jobId, Authentication authentication) { + UUID requestedBy = UUID.fromString(authentication.getName()); + return ResponseEntity.ok(IntegrationResponseMapper.toResponse( + getJob.handle(new GetIntegrationJobQuery(projectId, jobId, requestedBy)))); + } } diff --git a/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/BatchPushResponse.java b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/BatchPushResponse.java deleted file mode 100644 index 464f5b51..00000000 --- a/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/BatchPushResponse.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.kntro.reqsai.gateway.interfaces.rest.dto.response; - -import io.swagger.v3.oas.annotations.media.Schema; - -import java.util.List; - -/** Aggregate result of a push-all: per-story results plus pushed/failed counts. */ -@Schema(description = "Result of pushing all project stories to Jira") -public record BatchPushResponse(List results, int pushed, int failed) {} diff --git a/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/IntegrationJobResponse.java b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/IntegrationJobResponse.java new file mode 100644 index 00000000..534b9ed0 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/IntegrationJobResponse.java @@ -0,0 +1,28 @@ +package com.kntro.reqsai.gateway.interfaces.rest.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import org.jspecify.annotations.Nullable; + +import java.time.Instant; +import java.util.UUID; + +/** + * Snapshot of a background integration sync job. Returned by the 202 job-start endpoints and the + * job query endpoints, and broadcast with the same JSON shape on + * {@code /topic/projects/{projectId}/integration-jobs} — live frames and reload-recovery reads are + * interchangeable for the client. + */ +@Schema(description = "Background integration sync job (import / push-all) with live progress counters") +public record IntegrationJobResponse( + UUID id, + UUID projectId, + @Schema(description = "IMPORT | PUSH_ALL") String jobType, + @Schema(description = "RUNNING | COMPLETED | FAILED") String status, + int total, + int processed, + int succeeded, + int failed, + @Schema(description = "Terminal summary (fatal error or skipped-duplicates note); null otherwise") + @Nullable String message, + Instant createdAt, + @Nullable Instant finishedAt) {} diff --git a/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/JiraImportResponse.java b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/JiraImportResponse.java deleted file mode 100644 index 50a327f8..00000000 --- a/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/JiraImportResponse.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.kntro.reqsai.gateway.interfaces.rest.dto.response; - -import io.swagger.v3.oas.annotations.media.Schema; -import org.jspecify.annotations.Nullable; - -import java.util.List; -import java.util.UUID; - -/** Result of a Jira import: per-issue results plus imported/skipped/failed counts. */ -@Schema(description = "Result of importing Jira issues as user stories") -public record JiraImportResponse(int imported, int skipped, int failed, List results) { - - @Schema(description = "Per-issue import result; status is imported | duplicate | failed") - public record Result( - String jiraIssueKey, - @Nullable UUID storyId, - String status, - @Nullable String message) {} -} diff --git a/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/mappers/response/IntegrationResponseMapper.java b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/mappers/response/IntegrationResponseMapper.java index ba9648b1..94f2fa54 100644 --- a/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/mappers/response/IntegrationResponseMapper.java +++ b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/mappers/response/IntegrationResponseMapper.java @@ -3,21 +3,18 @@ import com.kntro.reqsai.gateway.application.port.IntegrationProvider.RemoteIssueType; import com.kntro.reqsai.gateway.application.port.IntegrationProvider.RemoteProject; import com.kntro.reqsai.gateway.application.port.JiraOAuthPort.Site; -import com.kntro.reqsai.gateway.application.result.BatchImportResult; -import com.kntro.reqsai.gateway.application.result.BatchPushResult; import com.kntro.reqsai.gateway.application.result.ConnectionTestResult; import com.kntro.reqsai.gateway.application.result.ImportPreview; -import com.kntro.reqsai.gateway.application.result.ImportStoryResult; import com.kntro.reqsai.gateway.application.result.StoryPushResult; import com.kntro.reqsai.gateway.domain.model.IntegrationConnection; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJob; import com.kntro.reqsai.gateway.domain.model.ProjectIntegrationTarget; -import com.kntro.reqsai.gateway.interfaces.rest.dto.response.BatchPushResponse; import com.kntro.reqsai.gateway.interfaces.rest.dto.response.ConnectionTestResponse; import com.kntro.reqsai.gateway.interfaces.rest.dto.response.IntegrationConnectionResponse; +import com.kntro.reqsai.gateway.interfaces.rest.dto.response.IntegrationJobResponse; import com.kntro.reqsai.gateway.interfaces.rest.dto.response.JiraIssueTypeResponse; import com.kntro.reqsai.gateway.interfaces.rest.dto.response.JiraOAuthSiteResponse; import com.kntro.reqsai.gateway.interfaces.rest.dto.response.JiraImportPreviewResponse; -import com.kntro.reqsai.gateway.interfaces.rest.dto.response.JiraImportResponse; import com.kntro.reqsai.gateway.interfaces.rest.dto.response.JiraProjectResponse; import com.kntro.reqsai.gateway.interfaces.rest.dto.response.JiraPushResultResponse; import com.kntro.reqsai.gateway.interfaces.rest.dto.response.ProjectJiraTargetResponse; @@ -74,11 +71,20 @@ public static JiraPushResultResponse toResponse(StoryPushResult r) { return new JiraPushResultResponse(r.storyId(), r.jiraIssueKey(), r.jiraIssueUrl(), r.error()); } - public static BatchPushResponse toResponse(BatchPushResult r) { - return new BatchPushResponse( - r.results().stream().map(IntegrationResponseMapper::toResponse).toList(), - r.pushed(), - r.failed()); + /** Same field-by-field shape as the {@code IntegrationJobMessage} broadcast over STOMP. */ + public static IntegrationJobResponse toResponse(IntegrationSyncJob job) { + return new IntegrationJobResponse( + job.getId(), + job.getProjectId(), + job.getJobType().name(), + job.getStatus().name(), + job.getTotal(), + job.getProcessed(), + job.getSucceeded(), + job.getFailed(), + job.getMessage(), + job.getCreatedAt(), + job.getFinishedAt()); } public static JiraImportPreviewResponse toResponse(ImportPreview p) { @@ -90,15 +96,4 @@ public static JiraImportPreviewResponse toResponse(ImportPreview p) { .toList()); } - public static JiraImportResponse toResponse(BatchImportResult r) { - return new JiraImportResponse( - r.imported(), - r.skipped(), - r.failed(), - r.results().stream().map(IntegrationResponseMapper::toResponse).toList()); - } - - public static JiraImportResponse.Result toResponse(ImportStoryResult r) { - return new JiraImportResponse.Result(r.jiraIssueKey(), r.storyId(), r.status().wire(), r.message()); - } } diff --git a/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/swagger/ProjectIntegrationController.java b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/swagger/ProjectIntegrationController.java index f393a0a5..0775956c 100644 --- a/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/swagger/ProjectIntegrationController.java +++ b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/swagger/ProjectIntegrationController.java @@ -2,9 +2,8 @@ import com.kntro.reqsai.gateway.interfaces.rest.dto.request.ImportJiraStoriesRequest; import com.kntro.reqsai.gateway.interfaces.rest.dto.request.SaveProjectTargetRequest; -import com.kntro.reqsai.gateway.interfaces.rest.dto.response.BatchPushResponse; +import com.kntro.reqsai.gateway.interfaces.rest.dto.response.IntegrationJobResponse; import com.kntro.reqsai.gateway.interfaces.rest.dto.response.JiraImportPreviewResponse; -import com.kntro.reqsai.gateway.interfaces.rest.dto.response.JiraImportResponse; import com.kntro.reqsai.gateway.interfaces.rest.dto.response.JiraPushResultResponse; import com.kntro.reqsai.gateway.interfaces.rest.dto.response.ProjectJiraTargetResponse; import com.kntro.reqsai.shared.infrastructure.configuration.ApiVersioning; @@ -31,7 +30,9 @@ import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import java.util.List; import java.util.UUID; @RequestMapping( @@ -94,18 +95,22 @@ ResponseEntity pushStory( @Parameter(description = "Story UUID") @PathVariable UUID storyId, Authentication authentication); - @Operation(summary = "Push all stories to Jira", + @Operation(summary = "Push all stories to Jira (async job)", description = """ - Pushes every project story to the Jira target. Per-story failures are captured in the - results and do not abort the batch. 409 when no target is configured.""") - @ApiResponse(responseCode = "200", description = "Batch push result", + Starts a background job that pushes every project story to the Jira target and returns + 202 immediately with the job snapshot. Progress is broadcast on + /topic/projects/{projectId}/integration-jobs and queryable via the jobs endpoints. + Per-story failures are counted without aborting the job. 409 when no target is + configured (INTEGRATION_TARGET_NOT_CONFIGURED) or a push-all job is already running + (INTEGRATION_JOB_ALREADY_RUNNING).""") + @ApiResponse(responseCode = "202", description = "Job accepted and running", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, - schema = @Schema(implementation = BatchPushResponse.class))) + schema = @Schema(implementation = IntegrationJobResponse.class))) @ApiResponseConflict @ApiStandardErrorResponses @SecurityRequirement(name = OpenApiConfiguration.BEARER_SCHEME) @PostMapping(value = "/stories/push-all", version = ApiVersioning.V1) - ResponseEntity pushAllStories( + ResponseEntity pushAllStories( @Parameter(description = "Project UUID") @PathVariable UUID projectId, Authentication authentication); @@ -125,21 +130,56 @@ ResponseEntity previewImport( @Parameter(description = "Project UUID") @PathVariable UUID projectId, Authentication authentication); - @Operation(summary = "Import Jira issues as stories", + @Operation(summary = "Import Jira issues as stories (async job)", description = """ - Pulls Jira issues from the project's target and creates them as user stories (LLM - mapping + duplicate detection reused from discovery). Body {issueKeys?} restricts the - import; omit/empty imports all eligible issues. Per-issue failures are captured without - aborting the batch; duplicates are counted as skipped. 409 when no target is configured.""") - @ApiResponse(responseCode = "200", description = "Import result", + Starts a background job that pulls Jira issues from the project's target and creates + them as user stories (LLM mapping + duplicate detection reused from discovery), and + returns 202 immediately with the job snapshot. Body {issueKeys?} restricts the import; + omit/empty imports all eligible issues. Progress is broadcast on + /topic/projects/{projectId}/integration-jobs and queryable via the jobs endpoints. + Per-issue failures are counted without aborting the job; duplicates count as processed + only. 409 when no target is configured (INTEGRATION_TARGET_NOT_CONFIGURED) or an import + job is already running (INTEGRATION_JOB_ALREADY_RUNNING).""") + @ApiResponse(responseCode = "202", description = "Job accepted and running", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, - schema = @Schema(implementation = JiraImportResponse.class))) + schema = @Schema(implementation = IntegrationJobResponse.class))) @ApiResponseConflict @ApiStandardErrorResponses @SecurityRequirement(name = OpenApiConfiguration.BEARER_SCHEME) @PostMapping(value = "/import", version = ApiVersioning.V1) - ResponseEntity importStories( + ResponseEntity importStories( @Parameter(description = "Project UUID") @PathVariable UUID projectId, @RequestBody(required = false) ImportJiraStoriesRequest request, Authentication authentication); + + @Operation(summary = "List integration sync jobs", + description = """ + Lists the project's background sync jobs. active=true returns only RUNNING jobs (what a + reloaded page asks first to re-attach its progress banner); otherwise the most recent + ~10 jobs of any status are returned, newest first.""") + @ApiResponse(responseCode = "200", description = "Sync jobs", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = IntegrationJobResponse[].class))) + @ApiStandardErrorResponses + @SecurityRequirement(name = OpenApiConfiguration.BEARER_SCHEME) + @GetMapping(value = "/jobs", version = ApiVersioning.V1) + ResponseEntity> listJobs( + @Parameter(description = "Project UUID") @PathVariable UUID projectId, + @Parameter(description = "Return only RUNNING jobs") + @RequestParam(name = "active", required = false, defaultValue = "false") boolean active, + Authentication authentication); + + @Operation(summary = "Get one integration sync job", + description = "Returns one background sync job of the project; 404 when unknown to this project.") + @ApiResponse(responseCode = "200", description = "Sync job", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = IntegrationJobResponse.class))) + @ApiResponseNotFound + @ApiStandardErrorResponses + @SecurityRequirement(name = OpenApiConfiguration.BEARER_SCHEME) + @GetMapping(value = "/jobs/{jobId}", version = ApiVersioning.V1) + ResponseEntity getJob( + @Parameter(description = "Project UUID") @PathVariable UUID projectId, + @Parameter(description = "Job UUID") @PathVariable UUID jobId, + Authentication authentication); } diff --git a/src/test/java/com/kntro/reqsai/gateway/application/handler/ImportJiraStoriesCommandHandlerTest.java b/src/test/java/com/kntro/reqsai/gateway/application/handler/ImportJiraStoriesCommandHandlerTest.java index e370101b..a53621ee 100644 --- a/src/test/java/com/kntro/reqsai/gateway/application/handler/ImportJiraStoriesCommandHandlerTest.java +++ b/src/test/java/com/kntro/reqsai/gateway/application/handler/ImportJiraStoriesCommandHandlerTest.java @@ -1,12 +1,12 @@ package com.kntro.reqsai.gateway.application.handler; import com.kntro.reqsai.gateway.application.command.ImportJiraStoriesCommand; -import com.kntro.reqsai.gateway.application.port.IntegrationProvider.RemoteIssue; +import com.kntro.reqsai.gateway.application.port.IntegrationJobLauncher; import com.kntro.reqsai.gateway.application.port.ProjectIntegrationTargetRepository; -import com.kntro.reqsai.gateway.application.result.BatchImportResult; -import com.kntro.reqsai.gateway.application.result.ImportStoryResult; -import com.kntro.reqsai.gateway.application.service.JiraImportService; -import com.kntro.reqsai.gateway.application.service.StoryPushService.PushContext; +import com.kntro.reqsai.gateway.application.service.IntegrationSyncJobStarter; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJob; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJobStatus; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJobType; import com.kntro.reqsai.gateway.domain.model.ProjectIntegrationTarget; import com.kntro.reqsai.shared.domain.exception.DomainException; import org.junit.jupiter.api.DisplayName; @@ -24,7 +24,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -32,7 +31,7 @@ @Tag("unit") @ExtendWith(MockitoExtension.class) -@DisplayName("Application: Import Jira stories command handler") +@DisplayName("Application: Import Jira stories command handler (async job)") class ImportJiraStoriesCommandHandlerTest { private static final UUID PROJECT = UUID.randomUUID(); @@ -41,48 +40,42 @@ class ImportJiraStoriesCommandHandlerTest { @Mock private ProjectIntegrationTargetRepository targets; @Mock - private JiraImportService importService; + private IntegrationSyncJobStarter starter; + @Mock + private IntegrationJobLauncher launcher; @InjectMocks private ImportJiraStoriesCommandHandler handler; @Test - @DisplayName("imports each eligible issue, counting imported vs duplicate vs failed") - void imports_counts_outcomes() { - stubTargetAndIssues(List.of( - issue("PAY-1"), issue("PAY-2"), issue("PAY-3"))); - UUID storyId = UUID.randomUUID(); - when(importService.importIssue(eq(PROJECT), argKey("PAY-1"))) - .thenReturn(ImportStoryResult.imported("PAY-1", storyId)); - when(importService.importIssue(eq(PROJECT), argKey("PAY-2"))) - .thenReturn(ImportStoryResult.duplicate("PAY-2")); - when(importService.importIssue(eq(PROJECT), argKey("PAY-3"))) - .thenReturn(ImportStoryResult.failed("PAY-3", "Jira import failed: boom")); - - BatchImportResult result = handler.handle(new ImportJiraStoriesCommand(PROJECT, null, USER)); - - assertThat(result.imported()).isEqualTo(1); - assertThat(result.skipped()).isEqualTo(1); - assertThat(result.failed()).isEqualTo(1); - assertThat(result.results()).hasSize(3); + @DisplayName("creates a RUNNING job and dispatches the async import") + void starts_job_and_launches() { + stubTarget(); + IntegrationSyncJob job = new IntegrationSyncJob(PROJECT, IntegrationSyncJobType.IMPORT, 2, USER); + when(starter.start(PROJECT, IntegrationSyncJobType.IMPORT, 2, USER)).thenReturn(job); + + IntegrationSyncJob result = handler.handle( + new ImportJiraStoriesCommand(PROJECT, List.of("PAY-1", "PAY-2"), USER)); + + assertThat(result.getStatus()).isEqualTo(IntegrationSyncJobStatus.RUNNING); + assertThat(result.getTotal()).isEqualTo(2); + verify(launcher).launchImport(job.getId(), PROJECT, List.of("PAY-1", "PAY-2")); } @Test - @DisplayName("issueKeys restricts the import to the requested keys") - void issue_keys_filter() { - stubTargetAndIssues(List.of(issue("PAY-1"), issue("PAY-2"))); - when(importService.importIssue(eq(PROJECT), argKey("PAY-2"))) - .thenReturn(ImportStoryResult.imported("PAY-2", UUID.randomUUID())); - - BatchImportResult result = handler.handle( - new ImportJiraStoriesCommand(PROJECT, List.of("PAY-2"), USER)); - - assertThat(result.imported()).isEqualTo(1); - assertThat(result.results()).extracting(ImportStoryResult::jiraIssueKey).containsExactly("PAY-2"); - verify(importService, never()).importIssue(eq(PROJECT), argKey("PAY-1")); + @DisplayName("a full import (no issueKeys) starts with total 0 until the worker resolves it") + void full_import_starts_with_unknown_total() { + stubTarget(); + IntegrationSyncJob job = new IntegrationSyncJob(PROJECT, IntegrationSyncJobType.IMPORT, 0, USER); + when(starter.start(PROJECT, IntegrationSyncJobType.IMPORT, 0, USER)).thenReturn(job); + + IntegrationSyncJob result = handler.handle(new ImportJiraStoriesCommand(PROJECT, null, USER)); + + assertThat(result.getTotal()).isZero(); + verify(launcher).launchImport(job.getId(), PROJECT, null); } @Test - @DisplayName("409 INTEGRATION_TARGET_NOT_CONFIGURED when no target exists") + @DisplayName("409 INTEGRATION_TARGET_NOT_CONFIGURED when no target exists; nothing is launched") void no_target_conflicts() { when(targets.findByProjectId(PROJECT)).thenReturn(Optional.empty()); @@ -90,21 +83,25 @@ void no_target_conflicts() { .isInstanceOf(DomainException.class) .satisfies(e -> assertThat(((DomainException) e).error().code()) .isEqualTo("INTEGRATION_TARGET_NOT_CONFIGURED")); + verify(launcher, never()).launchImport(any(), any(), any()); } - private void stubTargetAndIssues(List issues) { - ProjectIntegrationTarget target = mock(ProjectIntegrationTarget.class); - when(targets.findByProjectId(PROJECT)).thenReturn(Optional.of(target)); - PushContext ctx = mock(PushContext.class); - when(importService.contextFor(target)).thenReturn(ctx); - when(importService.fetchIssues(ctx)).thenReturn(issues); - } + @Test + @DisplayName("propagates the starter's 409 INTEGRATION_JOB_ALREADY_RUNNING without launching") + void running_job_conflicts() { + stubTarget(); + when(starter.start(PROJECT, IntegrationSyncJobType.IMPORT, 0, USER)) + .thenThrow(com.kntro.reqsai.gateway.domain.exception.IntegrationsExceptions + .jobAlreadyRunning(PROJECT, IntegrationSyncJobType.IMPORT.name())); - private static RemoteIssue issue(String key) { - return new RemoteIssue(key, "Summary " + key, "Story", "desc", "MEDIUM"); + assertThatThrownBy(() -> handler.handle(new ImportJiraStoriesCommand(PROJECT, null, USER))) + .isInstanceOf(DomainException.class) + .satisfies(e -> assertThat(((DomainException) e).error().code()) + .isEqualTo("INTEGRATION_JOB_ALREADY_RUNNING")); + verify(launcher, never()).launchImport(any(), any(), any()); } - private static RemoteIssue argKey(String key) { - return org.mockito.ArgumentMatchers.argThat(i -> i != null && key.equals(i.issueKey())); + private void stubTarget() { + when(targets.findByProjectId(PROJECT)).thenReturn(Optional.of(mock(ProjectIntegrationTarget.class))); } } diff --git a/src/test/java/com/kntro/reqsai/gateway/application/handler/PushAllStoriesCommandHandlerTest.java b/src/test/java/com/kntro/reqsai/gateway/application/handler/PushAllStoriesCommandHandlerTest.java index 8be4b288..54ad64f8 100644 --- a/src/test/java/com/kntro/reqsai/gateway/application/handler/PushAllStoriesCommandHandlerTest.java +++ b/src/test/java/com/kntro/reqsai/gateway/application/handler/PushAllStoriesCommandHandlerTest.java @@ -3,91 +3,82 @@ import com.kntro.reqsai.discovery.api.DiscoveryStoryReadPort; import com.kntro.reqsai.discovery.api.StoryView; import com.kntro.reqsai.gateway.application.command.PushAllStoriesCommand; -import com.kntro.reqsai.gateway.application.port.IntegrationConnectionRepository; -import com.kntro.reqsai.gateway.application.port.IntegrationProvider; -import com.kntro.reqsai.gateway.application.port.IntegrationProvider.PushedIssue; +import com.kntro.reqsai.gateway.application.port.IntegrationJobLauncher; import com.kntro.reqsai.gateway.application.port.ProjectIntegrationTargetRepository; -import com.kntro.reqsai.gateway.application.result.BatchPushResult; -import com.kntro.reqsai.gateway.application.service.ProviderCredentialsFactory; -import com.kntro.reqsai.gateway.application.service.ProviderRegistry; -import com.kntro.reqsai.gateway.application.service.StoryPushService; -import com.kntro.reqsai.gateway.domain.model.ConnectionStatus; -import com.kntro.reqsai.gateway.domain.model.IntegrationConnection; -import com.kntro.reqsai.gateway.domain.model.IntegrationProviderType; +import com.kntro.reqsai.gateway.application.service.IntegrationSyncJobStarter; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJob; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJobStatus; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJobType; import com.kntro.reqsai.gateway.domain.model.ProjectIntegrationTarget; -import com.kntro.reqsai.gateway.infrastructure.exception.IntegrationsInfrastructureExceptions; -import org.junit.jupiter.api.BeforeEach; +import com.kntro.reqsai.shared.domain.exception.DomainException; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import java.time.Instant; import java.util.List; import java.util.Optional; import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -@DisplayName("Application: Push all stories (partial failure)") +@Tag("unit") @ExtendWith(MockitoExtension.class) +@DisplayName("Application: Push all stories command handler (async job)") class PushAllStoriesCommandHandlerTest { - @Mock private ProjectIntegrationTargetRepository targets; - @Mock private DiscoveryStoryReadPort stories; - @Mock private IntegrationConnectionRepository connections; - @Mock private IntegrationProvider jiraProvider; - @Mock private ProviderCredentialsFactory credentialsFactory; + private static final UUID PROJECT = UUID.randomUUID(); + private static final UUID USER = UUID.randomUUID(); + @Mock + private ProjectIntegrationTargetRepository targets; + @Mock + private DiscoveryStoryReadPort stories; + @Mock + private IntegrationSyncJobStarter starter; + @Mock + private IntegrationJobLauncher launcher; + @InjectMocks private PushAllStoriesCommandHandler handler; - @BeforeEach - void setUp() { - when(jiraProvider.type()).thenReturn(IntegrationProviderType.JIRA); - StoryPushService pushService = new StoryPushService( - connections, new ProviderRegistry(List.of(jiraProvider)), credentialsFactory); - handler = new PushAllStoriesCommandHandler(targets, stories, pushService); - } - @Test - @DisplayName("captures a per-story failure without aborting the batch") - void captures_partial_failure() { - UUID projectId = UUID.randomUUID(); - UUID connectionId = UUID.randomUUID(); - ProjectIntegrationTarget target = new ProjectIntegrationTarget(projectId, connectionId, "PAY", "Story"); - IntegrationConnection connection = new IntegrationConnection( - UUID.randomUUID(), IntegrationProviderType.JIRA, "https://acme.atlassian.net", - "pm@acme.com", "tok", Instant.now()); - - when(targets.findByProjectId(projectId)).thenReturn(Optional.of(target)); - when(connections.findById(connectionId)).thenReturn(Optional.of(connection)); - when(credentialsFactory.from(connection)).thenReturn( - IntegrationProvider.ProviderCredentials.apiToken("https://acme.atlassian.net", "pm@acme.com", "tok")); + @DisplayName("creates a RUNNING job with the story count as total and dispatches the async push") + void starts_job_and_launches() { + when(targets.findByProjectId(PROJECT)).thenReturn(Optional.of(mock(ProjectIntegrationTarget.class))); + when(stories.listStories(PROJECT)).thenReturn(List.of(story(), story(), story())); + IntegrationSyncJob job = new IntegrationSyncJob(PROJECT, IntegrationSyncJobType.PUSH_ALL, 3, USER); + when(starter.start(PROJECT, IntegrationSyncJobType.PUSH_ALL, 3, USER)).thenReturn(job); - StoryView ok = story(projectId, "Good story"); - StoryView bad = story(projectId, "Bad story"); - when(stories.listStories(projectId)).thenReturn(List.of(ok, bad)); + IntegrationSyncJob result = handler.handle(new PushAllStoriesCommand(PROJECT, USER)); - when(jiraProvider.pushStory(any(), eq("PAY"), eq("Story"), eq(ok))) - .thenReturn(new PushedIssue("PAY-1", "https://acme.atlassian.net/browse/PAY-1")); - when(jiraProvider.pushStory(any(), eq("PAY"), eq("Story"), eq(bad))) - .thenThrow(IntegrationsInfrastructureExceptions.jiraPushFailed("400")); + assertThat(result.getStatus()).isEqualTo(IntegrationSyncJobStatus.RUNNING); + assertThat(result.getTotal()).isEqualTo(3); + assertThat(result.getProcessed()).isZero(); + verify(launcher).launchPushAll(job.getId(), PROJECT); + } - BatchPushResult result = handler.handle(new PushAllStoriesCommand(projectId, UUID.randomUUID())); + @Test + @DisplayName("409 INTEGRATION_TARGET_NOT_CONFIGURED when no target exists; nothing is launched") + void no_target_conflicts() { + when(targets.findByProjectId(PROJECT)).thenReturn(Optional.empty()); - assertThat(result.pushed()).isEqualTo(1); - assertThat(result.failed()).isEqualTo(1); - assertThat(result.results()).hasSize(2); - assertThat(result.results().get(0).jiraIssueKey()).isEqualTo("PAY-1"); - assertThat(result.results().get(1).error()).isEqualTo("JIRA_PUSH_FAILED"); - assertThat(result.results().get(1).jiraIssueKey()).isNull(); + assertThatThrownBy(() -> handler.handle(new PushAllStoriesCommand(PROJECT, USER))) + .isInstanceOf(DomainException.class) + .satisfies(e -> assertThat(((DomainException) e).error().code()) + .isEqualTo("INTEGRATION_TARGET_NOT_CONFIGURED")); + verify(launcher, never()).launchPushAll(any(), any()); } - private static StoryView story(UUID projectId, String title) { - return new StoryView(UUID.randomUUID(), projectId, title, "user", "do", "benefit", "MEDIUM", null, List.of()); + private static StoryView story() { + return new StoryView(UUID.randomUUID(), PROJECT, "Title", "user", "do", "benefit", "MEDIUM", null, List.of()); } } diff --git a/src/test/java/com/kntro/reqsai/gateway/interfaces/rest/JiraImportIntegrationTest.java b/src/test/java/com/kntro/reqsai/gateway/interfaces/rest/JiraImportIntegrationTest.java index 575b3d57..5e02b077 100644 --- a/src/test/java/com/kntro/reqsai/gateway/interfaces/rest/JiraImportIntegrationTest.java +++ b/src/test/java/com/kntro/reqsai/gateway/interfaces/rest/JiraImportIntegrationTest.java @@ -25,10 +25,11 @@ import static org.assertj.core.api.Assertions.assertThat; /** - * End-to-end test of the Jira IMPORT slice: connects Jira at the org level, sets a project target, then - * imports the two stubbed Jira issues into the backlog as user stories — asserting stories are created and - * that a near-duplicate is skipped (both stubbed issues map to the same stubbed generation output, so the - * second is detected as a duplicate of the first via the deterministic embedding stub). + * End-to-end test of the ASYNC Jira IMPORT slice: connects Jira at the org level, sets a project target, + * then starts the import job (202 + RUNNING snapshot), polls the job endpoint until the Spring Batch + * execution COMPLETEs in the right tenant schema, and asserts stories were created with a near-duplicate + * counted as processed-but-skipped (both stubbed issues map to the same stubbed generation output). Also + * asserts the batch metadata landed in the global {@code public.batch_*} tables, not a tenant schema. * *

              The Jira boundary is stubbed via {@link StubJiraProviderConfig} (no real network) and the LLM via * {@link StubRequirementGenerationConfig} (no real model — this test is NOT tagged {@code llm}). @@ -69,7 +70,7 @@ void previews_then_imports() throws Exception { assertThat(preview.get("total").asInt()).isEqualTo(2); assertThat(preview.get("issues")).hasSize(2); - // Import all eligible issues. + // Start the import job: 202 Accepted with a RUNNING snapshot. ResponseEntity importRes = client().post() .uri("/api/projects/{p}/integration/jira/import", projectId) .header("Authorization", TestJwtFactory.bearer(USER_ID, orgId, "ROLE_USER")) @@ -77,33 +78,69 @@ void previews_then_imports() throws Exception { .contentType(MediaType.APPLICATION_JSON) .body(Map.of()) .exchange((req, res) -> ResponseEntity.status(res.getStatusCode()).body(res.bodyTo(String.class))); - assertThat(importRes.getStatusCode()).isEqualTo(HttpStatus.OK); - JsonNode result = JSON.readTree(importRes.getBody()); - - // Both stubbed issues map (via the stubbed generation) to the same story, so the second collides. - assertThat(result.get("imported").asInt()).isEqualTo(1); - assertThat(result.get("skipped").asInt()).isEqualTo(1); + assertThat(importRes.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); + JsonNode accepted = JSON.readTree(importRes.getBody()); + assertThat(accepted.get("jobType").asText()).isEqualTo("IMPORT"); + assertThat(accepted.get("status").asText()).isEqualTo("RUNNING"); + String jobId = accepted.get("id").asText(); + + // The RUNNING job is visible to the reload-recovery query while it lasts, and the terminal + // state is reached by polling the job endpoint (the batch runs on the async executor). + JsonNode result = awaitJobCompletion(projectId, jobId, orgId); + + // Both stubbed issues map (via the stubbed generation) to the same story, so the second is a + // duplicate: processed but neither succeeded nor failed, summarized in the message. + assertThat(result.get("status").asText()).isEqualTo("COMPLETED"); + assertThat(result.get("total").asInt()).isEqualTo(2); + assertThat(result.get("processed").asInt()).isEqualTo(2); + assertThat(result.get("succeeded").asInt()).isEqualTo(1); assertThat(result.get("failed").asInt()).isZero(); - assertThat(result.get("results")).hasSize(2); - boolean hasImported = false; - boolean hasDuplicate = false; - for (JsonNode r : result.get("results")) { - if ("imported".equals(r.get("status").asText())) { - hasImported = true; - assertThat(r.hasNonNull("storyId")).isTrue(); - } else if ("duplicate".equals(r.get("status").asText())) { - hasDuplicate = true; - assertThat(r.hasNonNull("storyId")).isFalse(); - } - } - assertThat(hasImported).isTrue(); - assertThat(hasDuplicate).isTrue(); + assertThat(result.get("message").asText()).isEqualTo("1 duplicados omitidos"); + assertThat(result.hasNonNull("finishedAt")).isTrue(); - // Exactly one story persisted in the tenant backlog. + // Exactly one story persisted in the tenant backlog — written from the batch thread, proving + // the tenant context captured at launch was restored by the job listener. Integer storyCount = jdbcTemplate.queryForObject( "SELECT count(*) FROM \"" + schema + "\".user_stories WHERE project_id = ?::uuid", Integer.class, projectId.toString()); assertThat(storyCount).isEqualTo(1); + + // Spring Batch metadata lands in the global public schema (schema-qualified table prefix). + Integer batchInstances = jdbcTemplate.queryForObject( + "SELECT count(*) FROM public.batch_job_instance WHERE job_name = 'jiraImportJob'", + Integer.class); + assertThat(batchInstances).isGreaterThanOrEqualTo(1); + + // The jobs listing returns the finished job (most recent first). + ResponseEntity listRes = client().get() + .uri("/api/projects/{p}/integration/jira/jobs", projectId) + .header("Authorization", TestJwtFactory.bearer(USER_ID, orgId, "ROLE_USER")) + .header("Api-Version", "1") + .exchange((req, res) -> ResponseEntity.status(res.getStatusCode()).body(res.bodyTo(String.class))); + assertThat(listRes.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode jobsList = JSON.readTree(listRes.getBody()); + assertThat(jobsList.isArray()).isTrue(); + assertThat(jobsList.get(0).get("id").asText()).isEqualTo(jobId); + } + + /** Polls {@code GET .../jobs/{jobId}} until the job leaves RUNNING (max ~60s). */ + private JsonNode awaitJobCompletion(UUID projectId, String jobId, String orgId) throws Exception { + JsonNode job = null; + for (int i = 0; i < 200; i++) { + ResponseEntity res = client().get() + .uri("/api/projects/{p}/integration/jira/jobs/{j}", projectId, jobId) + .header("Authorization", TestJwtFactory.bearer(USER_ID, orgId, "ROLE_USER")) + .header("Api-Version", "1") + .exchange((req, response) -> ResponseEntity.status(response.getStatusCode()) + .body(response.bodyTo(String.class))); + assertThat(res.getStatusCode()).isEqualTo(HttpStatus.OK); + job = JSON.readTree(res.getBody()); + if (!"RUNNING".equals(job.get("status").asText())) { + return job; + } + Thread.sleep(300); + } + throw new AssertionError("Job " + jobId + " did not finish in time: " + job); } @Test diff --git a/src/test/java/com/kntro/reqsai/gateway/interfaces/rest/JiraIntegrationPushIntegrationTest.java b/src/test/java/com/kntro/reqsai/gateway/interfaces/rest/JiraIntegrationPushIntegrationTest.java index caa1fd5d..0b0a06a1 100644 --- a/src/test/java/com/kntro/reqsai/gateway/interfaces/rest/JiraIntegrationPushIntegrationTest.java +++ b/src/test/java/com/kntro/reqsai/gateway/interfaces/rest/JiraIntegrationPushIntegrationTest.java @@ -115,16 +115,51 @@ void connects_targets_and_pushes() throws Exception { assertThat(push.get("jiraIssueUrl").asText()).startsWith("https://acme.atlassian.net/browse/PAY-"); assertThat(push.hasNonNull("error")).isFalse(); - // push-all also succeeds for the single seeded story. + // push-all is now an async job: 202 with a RUNNING snapshot, then poll to COMPLETED. ResponseEntity pushAllRes = client().post() .uri("/api/projects/{p}/integration/jira/stories/push-all", projectId) .header("Authorization", TestJwtFactory.bearer(USER_ID, orgId, "ROLE_USER")) .header("Api-Version", "1") .exchange((req, res) -> ResponseEntity.status(res.getStatusCode()).body(res.bodyTo(String.class))); - assertThat(pushAllRes.getStatusCode()).isEqualTo(HttpStatus.OK); - JsonNode all = JSON.readTree(pushAllRes.getBody()); - assertThat(all.get("pushed").asInt()).isEqualTo(1); + assertThat(pushAllRes.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); + JsonNode accepted = JSON.readTree(pushAllRes.getBody()); + assertThat(accepted.get("jobType").asText()).isEqualTo("PUSH_ALL"); + assertThat(accepted.get("status").asText()).isEqualTo("RUNNING"); + assertThat(accepted.get("total").asInt()).isEqualTo(1); + + JsonNode all = awaitJobCompletion(projectId, accepted.get("id").asText(), orgId); + assertThat(all.get("status").asText()).isEqualTo("COMPLETED"); + assertThat(all.get("total").asInt()).isEqualTo(1); + assertThat(all.get("processed").asInt()).isEqualTo(1); + assertThat(all.get("succeeded").asInt()).isEqualTo(1); assertThat(all.get("failed").asInt()).isZero(); + assertThat(all.hasNonNull("finishedAt")).isTrue(); + + // Batch metadata for the push-all job lands in the global public schema. + Integer batchInstances = jdbcTemplate.queryForObject( + "SELECT count(*) FROM public.batch_job_instance WHERE job_name = 'jiraPushAllJob'", + Integer.class); + assertThat(batchInstances).isGreaterThanOrEqualTo(1); + } + + /** Polls {@code GET .../jobs/{jobId}} until the job leaves RUNNING (max ~60s). */ + private JsonNode awaitJobCompletion(UUID projectId, String jobId, String orgId) throws Exception { + JsonNode job = null; + for (int i = 0; i < 200; i++) { + ResponseEntity res = client().get() + .uri("/api/projects/{p}/integration/jira/jobs/{j}", projectId, jobId) + .header("Authorization", TestJwtFactory.bearer(USER_ID, orgId, "ROLE_USER")) + .header("Api-Version", "1") + .exchange((req, response) -> ResponseEntity.status(response.getStatusCode()) + .body(response.bodyTo(String.class))); + assertThat(res.getStatusCode()).isEqualTo(HttpStatus.OK); + job = JSON.readTree(res.getBody()); + if (!"RUNNING".equals(job.get("status").asText())) { + return job; + } + Thread.sleep(300); + } + throw new AssertionError("Job " + jobId + " did not finish in time: " + job); } @Test From 890fd55104cf3ced322f4e5376f94a91a9e48856 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Thu, 9 Jul 2026 22:09:03 -0500 Subject: [PATCH 60/72] test(gateway): cover sync job domain, starter guard and batch listeners Unit tests for the job counters/terminal transitions, the single-running-job 409 (pre-check and unique-index race), tenant capture into job parameters and restoration by the job listener, per-item progress accounting including skip-policy failures, and the import outcome mapping. --- .../IntegrationSyncJobStarterTest.java | 77 ++++++++++ .../domain/model/IntegrationSyncJobTest.java | 73 +++++++++ .../IntegrationJobExecutionListenerTest.java | 145 ++++++++++++++++++ .../IntegrationJobLauncherAdapterTest.java | 108 +++++++++++++ .../IntegrationJobProgressListenerTest.java | 90 +++++++++++ .../batch/JiraImportItemProcessorTest.java | 43 ++++++ 6 files changed, 536 insertions(+) create mode 100644 src/test/java/com/kntro/reqsai/gateway/application/service/IntegrationSyncJobStarterTest.java create mode 100644 src/test/java/com/kntro/reqsai/gateway/domain/model/IntegrationSyncJobTest.java create mode 100644 src/test/java/com/kntro/reqsai/gateway/infrastructure/batch/IntegrationJobExecutionListenerTest.java create mode 100644 src/test/java/com/kntro/reqsai/gateway/infrastructure/batch/IntegrationJobLauncherAdapterTest.java create mode 100644 src/test/java/com/kntro/reqsai/gateway/infrastructure/batch/IntegrationJobProgressListenerTest.java create mode 100644 src/test/java/com/kntro/reqsai/gateway/infrastructure/batch/JiraImportItemProcessorTest.java diff --git a/src/test/java/com/kntro/reqsai/gateway/application/service/IntegrationSyncJobStarterTest.java b/src/test/java/com/kntro/reqsai/gateway/application/service/IntegrationSyncJobStarterTest.java new file mode 100644 index 00000000..be68146a --- /dev/null +++ b/src/test/java/com/kntro/reqsai/gateway/application/service/IntegrationSyncJobStarterTest.java @@ -0,0 +1,77 @@ +package com.kntro.reqsai.gateway.application.service; + +import com.kntro.reqsai.gateway.application.port.IntegrationSyncJobRepository; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJob; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJobStatus; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJobType; +import com.kntro.reqsai.shared.domain.exception.DomainException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@Tag("unit") +@ExtendWith(MockitoExtension.class) +@DisplayName("Application: Integration sync job starter (single-running-job rule)") +class IntegrationSyncJobStarterTest { + + private static final UUID PROJECT = UUID.randomUUID(); + private static final UUID USER = UUID.randomUUID(); + + @Mock + private IntegrationSyncJobRepository jobs; + @InjectMocks + private IntegrationSyncJobStarter starter; + + @Test + @DisplayName("persists a RUNNING job when none of the same type is running") + void starts_when_free() { + when(jobs.existsRunning(PROJECT, IntegrationSyncJobType.IMPORT)).thenReturn(false); + when(jobs.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + IntegrationSyncJob job = starter.start(PROJECT, IntegrationSyncJobType.IMPORT, 3, USER); + + assertThat(job.getStatus()).isEqualTo(IntegrationSyncJobStatus.RUNNING); + assertThat(job.getProjectId()).isEqualTo(PROJECT); + assertThat(job.getJobType()).isEqualTo(IntegrationSyncJobType.IMPORT); + assertThat(job.getTotal()).isEqualTo(3); + assertThat(job.getRequestedBy()).isEqualTo(USER); + } + + @Test + @DisplayName("409 INTEGRATION_JOB_ALREADY_RUNNING when a job of the same type is running") + void conflicts_on_running_job() { + when(jobs.existsRunning(PROJECT, IntegrationSyncJobType.PUSH_ALL)).thenReturn(true); + + assertThatThrownBy(() -> starter.start(PROJECT, IntegrationSyncJobType.PUSH_ALL, 0, USER)) + .isInstanceOf(DomainException.class) + .satisfies(e -> assertThat(((DomainException) e).error().code()) + .isEqualTo("INTEGRATION_JOB_ALREADY_RUNNING")); + verify(jobs, never()).save(any()); + } + + @Test + @DisplayName("maps the unique-index race (partial index backstop) to the same 409") + void conflicts_on_racing_insert() { + when(jobs.existsRunning(PROJECT, IntegrationSyncJobType.IMPORT)).thenReturn(false); + when(jobs.save(any())).thenThrow(new DataIntegrityViolationException("uq_integration_sync_jobs_running")); + + assertThatThrownBy(() -> starter.start(PROJECT, IntegrationSyncJobType.IMPORT, 0, USER)) + .isInstanceOf(DomainException.class) + .satisfies(e -> assertThat(((DomainException) e).error().code()) + .isEqualTo("INTEGRATION_JOB_ALREADY_RUNNING")); + } +} diff --git a/src/test/java/com/kntro/reqsai/gateway/domain/model/IntegrationSyncJobTest.java b/src/test/java/com/kntro/reqsai/gateway/domain/model/IntegrationSyncJobTest.java new file mode 100644 index 00000000..acff3cb8 --- /dev/null +++ b/src/test/java/com/kntro/reqsai/gateway/domain/model/IntegrationSyncJobTest.java @@ -0,0 +1,73 @@ +package com.kntro.reqsai.gateway.domain.model; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@Tag("unit") +@DisplayName("Domain: Integration sync job") +class IntegrationSyncJobTest { + + private static final UUID PROJECT = UUID.randomUUID(); + + @Test + @DisplayName("starts RUNNING with zeroed counters and the known total") + void starts_running() { + IntegrationSyncJob job = new IntegrationSyncJob(PROJECT, IntegrationSyncJobType.IMPORT, 5, UUID.randomUUID()); + + assertThat(job.isRunning()).isTrue(); + assertThat(job.getStatus()).isEqualTo(IntegrationSyncJobStatus.RUNNING); + assertThat(job.getTotal()).isEqualTo(5); + assertThat(job.getProcessed()).isZero(); + assertThat(job.getSucceeded()).isZero(); + assertThat(job.getFailed()).isZero(); + assertThat(job.getFinishedAt()).isNull(); + } + + @Test + @DisplayName("counts items: success and failure both process; skipped only processes") + void counts_items() { + IntegrationSyncJob job = new IntegrationSyncJob(PROJECT, IntegrationSyncJobType.IMPORT, 0, null); + job.planTotal(3); + + job.recordSuccess(); + job.recordSkipped(); + job.recordFailure(); + + assertThat(job.getTotal()).isEqualTo(3); + assertThat(job.getProcessed()).isEqualTo(3); + assertThat(job.getSucceeded()).isEqualTo(1); + assertThat(job.getFailed()).isEqualTo(1); + } + + @Test + @DisplayName("complete() and fail() are terminal: they stamp finishedAt and freeze the job") + void terminal_transitions() { + IntegrationSyncJob completed = new IntegrationSyncJob(PROJECT, IntegrationSyncJobType.IMPORT, 1, null); + completed.complete("1 duplicados omitidos"); + assertThat(completed.getStatus()).isEqualTo(IntegrationSyncJobStatus.COMPLETED); + assertThat(completed.getMessage()).isEqualTo("1 duplicados omitidos"); + assertThat(completed.getFinishedAt()).isNotNull(); + assertThatThrownBy(completed::recordSuccess).isInstanceOf(IllegalStateException.class); + + IntegrationSyncJob failed = new IntegrationSyncJob(PROJECT, IntegrationSyncJobType.PUSH_ALL, 1, null); + failed.fail("Jira unreachable"); + assertThat(failed.getStatus()).isEqualTo(IntegrationSyncJobStatus.FAILED); + assertThat(failed.getMessage()).isEqualTo("Jira unreachable"); + assertThatThrownBy(() -> failed.complete(null)).isInstanceOf(IllegalStateException.class); + } + + @Test + @DisplayName("bounds the terminal message to the column size") + void truncates_message() { + IntegrationSyncJob job = new IntegrationSyncJob(PROJECT, IntegrationSyncJobType.IMPORT, 0, null); + job.fail("x".repeat(2000)); + + assertThat(job.getMessage()).hasSize(1000); + } +} diff --git a/src/test/java/com/kntro/reqsai/gateway/infrastructure/batch/IntegrationJobExecutionListenerTest.java b/src/test/java/com/kntro/reqsai/gateway/infrastructure/batch/IntegrationJobExecutionListenerTest.java new file mode 100644 index 00000000..2bf1b858 --- /dev/null +++ b/src/test/java/com/kntro/reqsai/gateway/infrastructure/batch/IntegrationJobExecutionListenerTest.java @@ -0,0 +1,145 @@ +package com.kntro.reqsai.gateway.infrastructure.batch; + +import com.kntro.reqsai.gateway.application.notification.IntegrationJobProgressNotifier; +import com.kntro.reqsai.gateway.application.port.IntegrationSyncJobRepository; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJob; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJobStatus; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJobType; +import com.kntro.reqsai.shared.infrastructure.persistence.multitenancy.TenantContext; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.batch.core.BatchStatus; +import org.springframework.batch.core.job.JobExecution; +import org.springframework.batch.core.job.JobInstance; +import org.springframework.batch.core.job.parameters.JobParametersBuilder; + +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@Tag("unit") +@ExtendWith(MockitoExtension.class) +@DisplayName("Batch: integration job execution listener (tenant framing + terminal projection)") +class IntegrationJobExecutionListenerTest { + + private static final UUID PROJECT = UUID.randomUUID(); + private static final UUID JOB_ID = UUID.randomUUID(); + + @Mock + private IntegrationSyncJobRepository jobs; + @Mock + private IntegrationJobProgressNotifier progress; + @InjectMocks + private IntegrationJobExecutionListener listener; + + @AfterEach + void clearTenant() { + TenantContext.clear(); + } + + @Test + @DisplayName("beforeJob restores the tenant captured into the job parameters") + void restores_tenant() { + listener.beforeJob(execution(IntegrationSyncJobType.IMPORT)); + + assertThat(TenantContext.getCurrentTenant()).isEqualTo("org-1"); + assertThat(TenantContext.getCurrentSchema()).isEqualTo("tenant_acme"); + } + + @Test + @DisplayName("afterJob COMPLETED completes the projection, notes skipped duplicates and publishes") + void completes_with_duplicates_note() { + IntegrationSyncJob job = runningJob(IntegrationSyncJobType.IMPORT); + job.planTotal(3); + job.recordSuccess(); + job.recordSkipped(); + job.recordSkipped(); + when(jobs.findById(JOB_ID)).thenReturn(Optional.of(job)); + when(jobs.save(job)).thenReturn(job); + JobExecution execution = execution(IntegrationSyncJobType.IMPORT); + execution.setStatus(BatchStatus.COMPLETED); + + listener.afterJob(execution); + + assertThat(job.getStatus()).isEqualTo(IntegrationSyncJobStatus.COMPLETED); + assertThat(job.getMessage()).isEqualTo("2 duplicados omitidos"); + assertThat(job.getFinishedAt()).isNotNull(); + verify(progress).publish(job); + assertThat(TenantContext.getCurrentSchema()).isNull(); + } + + @Test + @DisplayName("afterJob COMPLETED without duplicates leaves the message null") + void completes_silently() { + IntegrationSyncJob job = runningJob(IntegrationSyncJobType.PUSH_ALL); + job.planTotal(1); + job.recordSuccess(); + when(jobs.findById(JOB_ID)).thenReturn(Optional.of(job)); + when(jobs.save(job)).thenReturn(job); + JobExecution execution = execution(IntegrationSyncJobType.PUSH_ALL); + execution.setStatus(BatchStatus.COMPLETED); + + listener.afterJob(execution); + + assertThat(job.getStatus()).isEqualTo(IntegrationSyncJobStatus.COMPLETED); + assertThat(job.getMessage()).isNull(); + } + + @Test + @DisplayName("afterJob FAILED fails the projection with the execution's failure message") + void fails_with_message() { + IntegrationSyncJob job = runningJob(IntegrationSyncJobType.IMPORT); + when(jobs.findById(JOB_ID)).thenReturn(Optional.of(job)); + when(jobs.save(job)).thenReturn(job); + JobExecution execution = execution(IntegrationSyncJobType.IMPORT); + execution.setStatus(BatchStatus.FAILED); + execution.addFailureException(new IllegalStateException("Jira unreachable")); + + listener.afterJob(execution); + + assertThat(job.getStatus()).isEqualTo(IntegrationSyncJobStatus.FAILED); + assertThat(job.getMessage()).isEqualTo("Jira unreachable"); + verify(progress).publish(job); + assertThat(TenantContext.getCurrentSchema()).isNull(); + } + + @Test + @DisplayName("afterJob clears the tenant even when no RUNNING projection row exists") + void clears_tenant_without_row() { + when(jobs.findById(JOB_ID)).thenReturn(Optional.empty()); + JobExecution execution = execution(IntegrationSyncJobType.IMPORT); + execution.setStatus(BatchStatus.COMPLETED); + listener.beforeJob(execution); + + listener.afterJob(execution); + + verify(jobs, never()).save(any()); + assertThat(TenantContext.getCurrentSchema()).isNull(); + assertThat(TenantContext.getCurrentTenant()).isNull(); + } + + private static IntegrationSyncJob runningJob(IntegrationSyncJobType type) { + return new IntegrationSyncJob(PROJECT, type, 0, UUID.randomUUID()); + } + + private static JobExecution execution(IntegrationSyncJobType type) { + String jobName = type == IntegrationSyncJobType.IMPORT ? "jiraImportJob" : "jiraPushAllJob"; + return new JobExecution(1L, new JobInstance(1L, jobName), new JobParametersBuilder() + .addString(IntegrationJobParameters.DOMAIN_JOB_ID, JOB_ID.toString(), true) + .addString(IntegrationJobParameters.PROJECT_ID, PROJECT.toString(), false) + .addString(IntegrationJobParameters.TENANT_ID, "org-1", false) + .addString(IntegrationJobParameters.TENANT_SCHEMA, "tenant_acme", false) + .toJobParameters()); + } +} diff --git a/src/test/java/com/kntro/reqsai/gateway/infrastructure/batch/IntegrationJobLauncherAdapterTest.java b/src/test/java/com/kntro/reqsai/gateway/infrastructure/batch/IntegrationJobLauncherAdapterTest.java new file mode 100644 index 00000000..8b21152c --- /dev/null +++ b/src/test/java/com/kntro/reqsai/gateway/infrastructure/batch/IntegrationJobLauncherAdapterTest.java @@ -0,0 +1,108 @@ +package com.kntro.reqsai.gateway.infrastructure.batch; + +import com.kntro.reqsai.gateway.application.notification.IntegrationJobProgressNotifier; +import com.kntro.reqsai.gateway.application.port.IntegrationSyncJobRepository; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJob; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJobStatus; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJobType; +import com.kntro.reqsai.shared.infrastructure.persistence.multitenancy.TenantContext; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.batch.core.job.Job; +import org.springframework.batch.core.job.parameters.JobParameters; +import org.springframework.batch.core.launch.JobOperator; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@Tag("unit") +@ExtendWith(MockitoExtension.class) +@DisplayName("Batch: launcher adapter captures the tenant into job parameters") +class IntegrationJobLauncherAdapterTest { + + private static final UUID JOB_ID = UUID.randomUUID(); + private static final UUID PROJECT = UUID.randomUUID(); + + @Mock + private JobOperator jobOperator; + @Mock + private Job jiraImportJob; + @Mock + private Job jiraPushAllJob; + @Mock + private IntegrationSyncJobRepository jobs; + @Mock + private IntegrationJobProgressNotifier progress; + + @AfterEach + void clearTenant() { + TenantContext.clear(); + } + + private IntegrationJobLauncherAdapter adapter() { + return new IntegrationJobLauncherAdapter(jobOperator, jiraImportJob, jiraPushAllJob, jobs, progress); + } + + @Test + @DisplayName("launchImport snapshots the caller's tenant and passes it as job parameters") + void captures_tenant_snapshot() throws Exception { + TenantContext.setCurrentTenant("org-1"); + TenantContext.setCurrentSchema("tenant_acme"); + + adapter().launchImport(JOB_ID, PROJECT, List.of("PAY-1", "PAY-2")); + + ArgumentCaptor params = ArgumentCaptor.forClass(JobParameters.class); + verify(jobOperator).start(eq(jiraImportJob), params.capture()); + JobParameters captured = params.getValue(); + assertThat(captured.getString(IntegrationJobParameters.TENANT_ID)).isEqualTo("org-1"); + assertThat(captured.getString(IntegrationJobParameters.TENANT_SCHEMA)).isEqualTo("tenant_acme"); + assertThat(captured.getString(IntegrationJobParameters.PROJECT_ID)).isEqualTo(PROJECT.toString()); + assertThat(captured.getString(IntegrationJobParameters.ISSUE_KEYS)).isEqualTo("PAY-1,PAY-2"); + // Only the domain job id identifies the JobInstance: one API launch == one fresh instance. + assertThat(captured.getParameter(IntegrationJobParameters.DOMAIN_JOB_ID).identifying()).isTrue(); + assertThat(captured.getParameter(IntegrationJobParameters.TENANT_SCHEMA).identifying()).isFalse(); + } + + @Test + @DisplayName("launchPushAll omits issue keys and starts the push-all job") + void launches_push_all() throws Exception { + TenantContext.setCurrentTenant("org-1"); + TenantContext.setCurrentSchema("tenant_acme"); + + adapter().launchPushAll(JOB_ID, PROJECT); + + ArgumentCaptor params = ArgumentCaptor.forClass(JobParameters.class); + verify(jobOperator).start(eq(jiraPushAllJob), params.capture()); + assertThat(params.getValue().getParameter(IntegrationJobParameters.ISSUE_KEYS)).isNull(); + } + + @Test + @DisplayName("a failed launch fails the projection row so no client watches a phantom RUNNING job") + void fails_projection_when_launch_fails() throws Exception { + IntegrationSyncJob job = new IntegrationSyncJob(PROJECT, IntegrationSyncJobType.IMPORT, 0, null); + when(jobOperator.start(any(Job.class), any(JobParameters.class))) + .thenThrow(new IllegalStateException("no executor")); + when(jobs.findById(JOB_ID)).thenReturn(Optional.of(job)); + when(jobs.save(job)).thenReturn(job); + + assertThatThrownBy(() -> adapter().launchImport(JOB_ID, PROJECT, null)) + .isInstanceOf(IllegalStateException.class); + + assertThat(job.getStatus()).isEqualTo(IntegrationSyncJobStatus.FAILED); + verify(progress).publish(job); + } +} diff --git a/src/test/java/com/kntro/reqsai/gateway/infrastructure/batch/IntegrationJobProgressListenerTest.java b/src/test/java/com/kntro/reqsai/gateway/infrastructure/batch/IntegrationJobProgressListenerTest.java new file mode 100644 index 00000000..042ee540 --- /dev/null +++ b/src/test/java/com/kntro/reqsai/gateway/infrastructure/batch/IntegrationJobProgressListenerTest.java @@ -0,0 +1,90 @@ +package com.kntro.reqsai.gateway.infrastructure.batch; + +import com.kntro.reqsai.gateway.application.notification.IntegrationJobProgressNotifier; +import com.kntro.reqsai.gateway.application.port.IntegrationSyncJobRepository; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJob; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJobType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +@Tag("unit") +@ExtendWith(MockitoExtension.class) +@DisplayName("Batch: per-item progress listener updates the projection and publishes each snapshot") +class IntegrationJobProgressListenerTest { + + private static final UUID JOB_ID = UUID.randomUUID(); + + @Mock + private IntegrationSyncJobRepository jobs; + @Mock + private IntegrationJobProgressNotifier progress; + + private IntegrationSyncJob job; + private IntegrationJobProgressListener listener; + + @BeforeEach + void setUp() { + job = new IntegrationSyncJob(UUID.randomUUID(), IntegrationSyncJobType.IMPORT, 3, UUID.randomUUID()); + lenient().when(jobs.findById(JOB_ID)).thenReturn(Optional.of(job)); + lenient().when(jobs.save(job)).thenReturn(job); + listener = new IntegrationJobProgressListener(JOB_ID, jobs, progress); + } + + @Test + @DisplayName("afterProcess maps each outcome onto the counters and publishes per item") + void counts_outcomes() { + listener.afterProcess("item-1", SyncItemOutcome.SUCCEEDED); + listener.afterProcess("item-2", SyncItemOutcome.SKIPPED); + listener.afterProcess("item-3", SyncItemOutcome.FAILED); + + assertThat(job.getProcessed()).isEqualTo(3); + assertThat(job.getSucceeded()).isEqualTo(1); + assertThat(job.getFailed()).isEqualTo(1); + verify(progress, times(3)).publish(job); + } + + @Test + @DisplayName("a skipped (thrown-and-swallowed) item counts as failed and publishes") + void counts_skip_as_failure() { + listener.onSkipInProcess("item-1", new IllegalStateException("boom")); + + assertThat(job.getProcessed()).isEqualTo(1); + assertThat(job.getFailed()).isEqualTo(1); + assertThat(job.getSucceeded()).isZero(); + verify(progress).publish(job); + } + + @Test + @DisplayName("a null outcome (filtered item) is not counted") + void ignores_filtered_items() { + listener.afterProcess("item-1", null); + + assertThat(job.getProcessed()).isZero(); + verify(progress, never()).publish(any()); + } + + @Test + @DisplayName("a terminal projection row is left untouched") + void ignores_terminal_row() { + job.complete(null); + + listener.afterProcess("item-1", SyncItemOutcome.SUCCEEDED); + + verify(progress, never()).publish(any()); + } +} diff --git a/src/test/java/com/kntro/reqsai/gateway/infrastructure/batch/JiraImportItemProcessorTest.java b/src/test/java/com/kntro/reqsai/gateway/infrastructure/batch/JiraImportItemProcessorTest.java new file mode 100644 index 00000000..574f74d2 --- /dev/null +++ b/src/test/java/com/kntro/reqsai/gateway/infrastructure/batch/JiraImportItemProcessorTest.java @@ -0,0 +1,43 @@ +package com.kntro.reqsai.gateway.infrastructure.batch; + +import com.kntro.reqsai.gateway.application.port.IntegrationProvider.RemoteIssue; +import com.kntro.reqsai.gateway.application.result.ImportStoryResult; +import com.kntro.reqsai.gateway.application.service.JiraImportService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@Tag("unit") +@ExtendWith(MockitoExtension.class) +@DisplayName("Batch: import item processor maps service results to item outcomes") +class JiraImportItemProcessorTest { + + private static final UUID PROJECT = UUID.randomUUID(); + private static final RemoteIssue ISSUE = new RemoteIssue("PAY-1", "Summary", "Story", "desc", "MEDIUM"); + + @Mock + private JiraImportService importService; + + @Test + @DisplayName("imported -> SUCCEEDED, duplicate -> SKIPPED, failed -> FAILED") + void maps_outcomes() { + JiraImportItemProcessor processor = new JiraImportItemProcessor(importService, PROJECT); + + when(importService.importIssue(PROJECT, ISSUE)) + .thenReturn(ImportStoryResult.imported("PAY-1", UUID.randomUUID())) + .thenReturn(ImportStoryResult.duplicate("PAY-1")) + .thenReturn(ImportStoryResult.failed("PAY-1", "boom")); + + assertThat(processor.process(ISSUE)).isEqualTo(SyncItemOutcome.SUCCEEDED); + assertThat(processor.process(ISSUE)).isEqualTo(SyncItemOutcome.SKIPPED); + assertThat(processor.process(ISSUE)).isEqualTo(SyncItemOutcome.FAILED); + } +} From 40a1bcb5d228e47e565dd4bc611dc458523d889e Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Thu, 9 Jul 2026 22:12:50 -0500 Subject: [PATCH 61/72] docs: document the async jira sync jobs and spring batch decision Extends ADR-0023 with a didactic section on the Spring Batch engine (job/instance/execution, chunk step, skip policy, public-schema metadata in the multi-tenant setup, projection-over-BATCH-tables rationale, tenant propagation), records the 202 job contract in the changelog and refreshes the Jira guide. Also removes a stray code fence at the ADR tail. --- CHANGELOG.md | 34 ++++++++- docs/JIRA_INTEGRATION.md | 22 ++++-- .../adr/0023-third-party-integrations-jira.md | 71 ++++++++++++++++++- 3 files changed, 116 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d2b0e7d..16b0493f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,8 +46,8 @@ _Bounded-context implementation (iam, billing, workspace, discovery, gateway) in `GET/PUT/DELETE /projects/{projectId}/integration/jira/target` (single target per project; `404` when none), `POST /projects/{projectId}/integration/jira/stories/{storyId}/push` (`{storyId,jiraIssueKey,jiraIssueUrl}`; `409 INTEGRATION_TARGET_NOT_CONFIGURED` when no target), - `POST /projects/{projectId}/integration/jira/stories/push-all` (`{results,pushed,failed}` — per-story - failures captured without aborting the batch). All endpoints use header `Api-Version: 1`. + `POST /projects/{projectId}/integration/jira/stories/push-all` (now an **async job** — see the + "async sync jobs" entry below). All endpoints use header `Api-Version: 1`. - **Encryption at rest** — AES-256-GCM `AttributeConverter` (random 12-byte IV prepended to the ciphertext) keyed from `INTEGRATIONS_ENCRYPTION_KEY` (base64 32 bytes); a documented default key is provided for dev/test so the suite runs without a `.env`. @@ -91,6 +91,36 @@ _Bounded-context implementation (iam, billing, workspace, discovery, gateway) in `JIRA_OAUTH_NOT_CONFIGURED` (501), `JIRA_OAUTH_STATE_INVALID` (400), `JIRA_OAUTH_EXCHANGE_FAILED` (502). +### Added (Integrations / Jira — async sync jobs on Spring Batch, `feature/integrations-jira`) + +- **Jira import and push-all now run as background jobs** (ADR-0023). The blocking requests (minutes of + LLM transformations / issue creation) are gone: + - `POST /projects/{projectId}/integration/jira/import` (`{issueKeys?}`) and + `POST /projects/{projectId}/integration/jira/stories/push-all` now answer **`202 Accepted`** with an + `IntegrationJobResponse` snapshot `{id, projectId, jobType: IMPORT|PUSH_ALL, status: + RUNNING|COMPLETED|FAILED, total, processed, succeeded, failed, message, createdAt, finishedAt}`. + `409 INTEGRATION_JOB_ALREADY_RUNNING` when a job of the same type is already RUNNING for the project + (at most one, enforced by a partial unique index). The single-story push and the import preview stay + synchronous. + - **Live progress over STOMP** — every per-item update and the terminal state are broadcast as the full + `IntegrationJobResponse` JSON on **`/topic/projects/{projectId}/integration-jobs`** (JWT-authenticated + CONNECT, as with the other project topics). + - **Reload recovery** — `GET /projects/{projectId}/integration/jira/jobs?active=true` returns the + RUNNING jobs (re-attach the progress banner after a reload); without the flag the most recent ~10 of + any status. `GET .../jobs/{jobId}` returns one job (`404 INTEGRATION_JOB_NOT_FOUND`). Job reads are + gated by `INTEGRATION_READ`; job starts keep `INTEGRATION_SYNC`. + - **Durable state** — new tenant migration `V20260709100000__integration_sync_jobs.sql` + (`integration_sync_jobs`, the domain-facing projection and source of truth for the UI). Import + duplicates count toward `processed` only; the terminal message summarizes them + ("N duplicados omitidos"). A fatal error (e.g. Jira unreachable) marks the job `FAILED` with a message. + - **Engine: Spring Batch 6** — two chunk-oriented jobs (`jiraImportJob`, `jiraPushAllJob`; chunk size 5, + per-item skip policy so one bad item never aborts the run) behind the application-layer + `IntegrationJobLauncher` port. Batch metadata (`BATCH_JOB_INSTANCE`, `BATCH_JOB_EXECUTION`, ...) lives + in the **global `public` schema** (common migration `V20260709100001__spring_batch_metadata.sql`) with + the JobRepository schema-qualified via table prefix `public.BATCH_` — immune to the per-tenant + `search_path` routing. The caller's tenant is captured into job parameters and restored around the + execution. `spring.batch.job.enabled=false` (jobs run only on API demand). + ### Changed (Workspace — `feature/integrations-jira`) - **Org admins may now view organization general settings** — `GET /organizations/{orgId}` is now gated diff --git a/docs/JIRA_INTEGRATION.md b/docs/JIRA_INTEGRATION.md index aff54c90..4cfbeb13 100644 --- a/docs/JIRA_INTEGRATION.md +++ b/docs/JIRA_INTEGRATION.md @@ -125,9 +125,17 @@ Each user creates a personal API token: 1. **Connect** (once per org): Org Settings → **Integrations** → *Connect with Atlassian* (OAuth) or the API-token form. On multi-site Atlassian accounts you pick the site. 2. **Map** (per project): Project Settings → **Integrations** → choose the Jira project + issue type. -3. **Push**: from a story's detail (*Push to Jira*) or the backlog (*Push all to Jira*). The story title - becomes the issue summary; the description carries role/action/benefit + acceptance criteria - (Given/When/Then). +3. **Push**: from a story's detail (*Push to Jira*, synchronous) or the backlog (*Push all to Jira*). + The story title becomes the issue summary; the description carries role/action/benefit + acceptance + criteria (Given/When/Then). +4. **Import**: from the backlog, preview the eligible Jira issues (duplicates flagged) and import them + as user stories (LLM mapping + dedup). + +**Push-all and import run as background jobs** (Spring Batch): the POST returns `202` with a job +snapshot, progress streams on `/topic/projects/{projectId}/integration-jobs`, and after a reload the +client recovers via `GET .../integration/jira/jobs?active=true` (or `.../jobs/{jobId}`). At most one +running job per type and project (`409 INTEGRATION_JOB_ALREADY_RUNNING`). See ADR-0023, "Async sync +jobs on Spring Batch". Actions are RBAC-gated by new permissions: `INTEGRATION_READ`, `INTEGRATION_WRITE`, `INTEGRATION_DELETE`, `INTEGRATION_SYNC`. Org-level connection management requires org owner/admin. @@ -151,6 +159,8 @@ Actions are RBAC-gated by new permissions: `INTEGRATION_READ`, `INTEGRATION_WRIT - **Design:** [ADR-0023](adr/0023-third-party-integrations-jira.md) - **Module:** `com.kntro.reqsai.gateway` -- **Migrations (tenant):** `V21` connections, `V22` targets, `V23` OAuth columns -- **Config keys:** `reqsai.integrations.encryption-key`, `reqsai.integrations.jira.oauth.{client-id,client-secret,redirect-uri,state-secret}` -- **Endpoints:** `/api/organizations/{orgId}/integrations*` (connection, OAuth authorize-url/callback), `/api/projects/{projectId}/integration/jira*` (target, story push) +- **Migrations:** tenant — connections, targets, OAuth columns, `integration_sync_jobs`; common — + Spring Batch metadata in `public` (`V20260709100001__spring_batch_metadata.sql`) +- **Config keys:** `reqsai.integrations.encryption-key`, `reqsai.integrations.jira.oauth.{client-id,client-secret,redirect-uri,state-secret}`, `spring.batch.job.enabled=false` +- **Endpoints:** `/api/organizations/{orgId}/integrations*` (connection, OAuth authorize-url/callback), `/api/projects/{projectId}/integration/jira*` (target, story push, import preview/import, sync jobs) +- **Realtime:** `/topic/projects/{projectId}/integration-jobs` (job progress snapshots) diff --git a/docs/adr/0023-third-party-integrations-jira.md b/docs/adr/0023-third-party-integrations-jira.md index a3c0b3f9..b21b80df 100644 --- a/docs/adr/0023-third-party-integrations-jira.md +++ b/docs/adr/0023-third-party-integrations-jira.md @@ -145,10 +145,73 @@ resources. **Project** endpoints (target read/write/delete, story push) are gate path (`@authz.orgOwnerOrAdmin(#orgId, authentication)`) — administering an org-wide credential is an org-admin action, not a project permission. IAM identity/authn is untouched. +### Async sync jobs on Spring Batch (import / push-all) + +Import (dozens of issues × one LLM transformation each) and push-all take minutes; a blocking HTTP +request locks the UI and a page reload loses everything. Both operations therefore run as +**background jobs**: `POST .../import` and `POST .../stories/push-all` answer **202 Accepted** +immediately with an `IntegrationJobResponse` snapshot, progress streams over STOMP, and the state +survives reloads. The execution engine is **Spring Batch**, chosen over a hand-rolled `@Async` +worker because it gives us, out of the box, a persistent execution ledger, chunked transactions, +a declarative per-item skip policy, and an operational vocabulary (job/step/execution) that any +Spring developer can read. + +**Spring Batch in one paragraph.** A *Job* is a named, parameterized unit of work. Launching a job +with a set of *identifying* `JobParameters` creates a *JobInstance* (the logical run: "import for +domain job X"); every attempt at an instance is a *JobExecution* (rows in the `BATCH_*` metadata +tables, written by the *JobRepository*). A job is a sequence of *Steps*; our jobs have exactly one +**chunk-oriented step**, which reads items one at a time (*ItemReader*), transforms them +(*ItemProcessor*), and commits a transaction every N items (chunk size 5) — bounding transaction +size and, in restartable designs, lost work. `faultTolerant().skip(Exception)` makes a throwing +item a *skip* (counted, logged, execution continues) instead of a failure — exactly the +per-item-failure semantics the old synchronous endpoints had. We deliberately do **not** use Batch +restartability (each API launch is a fresh JobInstance keyed by the domain job UUID): a half-done +import is re-run safely because the discovery dedup skips already-imported stories. + +The topology (all in `gateway.infrastructure.batch` — the engine is an infrastructure detail behind +the application's `IntegrationJobLauncher` port; handlers and REST contract never see Batch types): + +- `jiraImportJob` / `jiraPushAllJob`, one chunk step each. Step-scoped readers resolve the work list + up front (Jira fetch / story list) and fix the projection's `total`; processors delegate one item + to the *existing* `JiraImportService` / `StoryPushService`; the writer is a no-op (services own + their side effects). +- **Tenant propagation**: the launcher captures `TenantContext` (tenant id + schema) on the request + thread into *non-identifying job parameters*; a `JobExecutionListener` restores it in `beforeJob` + and clears it in `afterJob` — the same snapshot-then-restore pattern as + `TenantAwareModuleListener`. The whole execution runs on one executor thread, so every Hibernate + session in the job resolves the caller's schema. +- **Batch metadata lives in `public`** (`V20260709100001__spring_batch_metadata.sql`, common + migration) with the JobRepository configured with table prefix **`public.BATCH_`** + (`shared/.../BatchConfiguration extends JdbcDefaultBatchConfiguration`). Rationale: the single + DataSource rewrites `search_path` per tenant, so unqualified `BATCH_*` SQL could land in an + arbitrary tenant schema; qualifying every metadata query makes it immune to whatever + `search_path` a pooled connection carries. Batch metadata is operational, org-agnostic data — + global like `public.organizations`. (Boot 4 / Batch 6 default to an in-memory "resourceless" + JobRepository; subclassing `JdbcDefaultBatchConfiguration` is the opt-in to durable JDBC metadata, + and `spring.batch.job.enabled=false` stops Boot replaying jobs at startup.) +- **Domain projection, not `BATCH_*` exposure**: the API reads/writes the per-tenant + `integration_sync_jobs` row (`{id, projectId, jobType, status, total, processed, succeeded, + failed, message, createdAt, finishedAt}`), updated per item by step listeners and finalized in + `afterJob`. The projection is tenant-scoped, queryable per project, and keeps the REST/STOMP + contract stable even if the engine changes; the `BATCH_*` tables stay an internal ledger + (1:1 linked via the identifying `domainJobId` parameter). +- **Realtime + recovery**: every counter update is broadcast as the full job snapshot on + `/topic/projects/{projectId}/integration-jobs` (same JSON as `IntegrationJobResponse`); a reloaded + client re-attaches via `GET .../jobs?active=true` and `GET .../jobs/{jobId}` — the durable row is + the source of truth, STOMP is only the push channel. +- **Concurrency**: at most one RUNNING job per (project, type), enforced in the application layer + (pre-check + partial unique index backstop → 409 `INTEGRATION_JOB_ALREADY_RUNNING`) — Batch's + JobInstance uniqueness is not used for this rule. +- Import duplicates count toward `processed` only (neither `succeeded` nor `failed`); the terminal + message summarizes them ("N duplicados omitidos"). A fatal error (e.g. Jira unreachable in the + reader) fails the step, and `afterJob` marks the projection FAILED with the first failure message. +- The single-story push and the import preview remain synchronous (fast, no job). + ### Error surface - Domain: `IntegrationsError` — `INTEGRATION_CONNECTION_NOT_FOUND` (404), `INTEGRATION_ALREADY_CONNECTED` (409), `INTEGRATION_TARGET_NOT_CONFIGURED` (409), + `INTEGRATION_JOB_ALREADY_RUNNING` (409), `INTEGRATION_JOB_NOT_FOUND` (404), `JIRA_PROJECT_NOT_FOUND` (404), `JIRA_OAUTH_NOT_CONFIGURED` (501), `JIRA_OAUTH_STATE_INVALID` (400). - Infrastructure: `IntegrationsInfrastructureError` — `JIRA_AUTH_FAILED` (401), `JIRA_UNREACHABLE` (502), `JIRA_PUSH_FAILED` (502), `INTEGRATION_ENCRYPTION_ERROR` (500), @@ -168,6 +231,8 @@ errors never leak the token or the internal cause to the client. relaxing the two unique indexes without a shape change to the endpoints. - Trade-off: a symmetric AES-GCM key in config means key rotation is a manual re-encrypt for now; a KMS-backed key can replace the converter's key source later without touching the model. -- The push-all endpoint captures per-story failures and continues the batch, so one bad story never - aborts the export of the rest. -``` +- The push-all and import jobs capture per-item failures (skip policy) and continue, so one bad + story/issue never aborts the rest; a stuck modal is gone — the UI follows the job row. +- Trade-off: Spring Batch adds metadata tables and a learning curve, but buys a durable execution + ledger, chunked transactions and skip/retry semantics we would otherwise reimplement; the engine + stays swappable behind the `IntegrationJobLauncher` port. From 536b15c34b78f6e1ccdf591b802e6741b47c4dc9 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Thu, 9 Jul 2026 22:52:43 -0500 Subject: [PATCH 62/72] feat(workspace): add STORY_DELETE permission to the catalog --- .../java/com/kntro/reqsai/workspace/domain/model/Permission.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/kntro/reqsai/workspace/domain/model/Permission.java b/src/main/java/com/kntro/reqsai/workspace/domain/model/Permission.java index 34922730..b47c8b24 100644 --- a/src/main/java/com/kntro/reqsai/workspace/domain/model/Permission.java +++ b/src/main/java/com/kntro/reqsai/workspace/domain/model/Permission.java @@ -49,6 +49,7 @@ public enum Permission { // User stories (backlog) STORY_READ, STORY_WRITE, + STORY_DELETE, // Third-party integrations (e.g. Jira). Org-level connection administration is gated by the // org owner/admin check; these project-scoped permissions gate the per-project target + push. From bb67553540f168d577cd3c259cb3f568452e731a Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Thu, 9 Jul 2026 22:52:53 -0500 Subject: [PATCH 63/72] feat(discovery): add user-story delete and batch-delete commands --- .../BatchDeleteUserStoriesCommand.java | 15 ++++ .../command/DeleteUserStoryCommand.java | 13 ++++ .../BatchDeleteUserStoriesCommandHandler.java | 38 ++++++++++ .../DeleteUserStoryCommandHandler.java | 36 ++++++++++ .../application/port/UserStoryRepository.java | 14 ++++ .../adapters/UserStoryRepositoryAdapter.java | 10 +++ .../repositories/UserStoryJpaRepository.java | 3 + ...chDeleteUserStoriesCommandHandlerTest.java | 71 +++++++++++++++++++ .../DeleteUserStoryCommandHandlerTest.java | 70 ++++++++++++++++++ 9 files changed, 270 insertions(+) create mode 100644 src/main/java/com/kntro/reqsai/discovery/application/command/BatchDeleteUserStoriesCommand.java create mode 100644 src/main/java/com/kntro/reqsai/discovery/application/command/DeleteUserStoryCommand.java create mode 100644 src/main/java/com/kntro/reqsai/discovery/application/handler/BatchDeleteUserStoriesCommandHandler.java create mode 100644 src/main/java/com/kntro/reqsai/discovery/application/handler/DeleteUserStoryCommandHandler.java create mode 100644 src/test/java/com/kntro/reqsai/discovery/application/handler/BatchDeleteUserStoriesCommandHandlerTest.java create mode 100644 src/test/java/com/kntro/reqsai/discovery/application/handler/DeleteUserStoryCommandHandlerTest.java diff --git a/src/main/java/com/kntro/reqsai/discovery/application/command/BatchDeleteUserStoriesCommand.java b/src/main/java/com/kntro/reqsai/discovery/application/command/BatchDeleteUserStoriesCommand.java new file mode 100644 index 00000000..cb8034c9 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/discovery/application/command/BatchDeleteUserStoriesCommand.java @@ -0,0 +1,15 @@ +package com.kntro.reqsai.discovery.application.command; + +import java.util.List; +import java.util.UUID; + +/** + * Intent to permanently delete several user stories of a project in one call. Ids that do not belong + * to {@code projectId} (unknown, or in another project/tenant) are silently skipped — the operation is + * best-effort and reports how many rows were actually deleted, never an error for a missing id. Each + * deleted story's acceptance criteria are removed with it (JPA cascade / orphan removal). + * + * @param projectId project the stories must belong to + * @param storyIds candidate stories to delete (order preserved; ids not in the project are skipped) + */ +public record BatchDeleteUserStoriesCommand(UUID projectId, List storyIds) {} diff --git a/src/main/java/com/kntro/reqsai/discovery/application/command/DeleteUserStoryCommand.java b/src/main/java/com/kntro/reqsai/discovery/application/command/DeleteUserStoryCommand.java new file mode 100644 index 00000000..5ccb64c6 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/discovery/application/command/DeleteUserStoryCommand.java @@ -0,0 +1,13 @@ +package com.kntro.reqsai.discovery.application.command; + +import java.util.UUID; + +/** + * Intent to permanently delete a single user story. Scoped to a project: the story must belong to + * {@code projectId} or the delete is rejected with a 404. The story's acceptance criteria are removed + * with it (JPA cascade / orphan removal on the aggregate). + * + * @param projectId project the story must belong to + * @param storyId story to delete + */ +public record DeleteUserStoryCommand(UUID projectId, UUID storyId) {} diff --git a/src/main/java/com/kntro/reqsai/discovery/application/handler/BatchDeleteUserStoriesCommandHandler.java b/src/main/java/com/kntro/reqsai/discovery/application/handler/BatchDeleteUserStoriesCommandHandler.java new file mode 100644 index 00000000..2abb42a5 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/discovery/application/handler/BatchDeleteUserStoriesCommandHandler.java @@ -0,0 +1,38 @@ +package com.kntro.reqsai.discovery.application.handler; + +import com.kntro.reqsai.discovery.application.command.BatchDeleteUserStoriesCommand; +import com.kntro.reqsai.discovery.application.port.UserStoryRepository; +import com.kntro.reqsai.discovery.domain.model.UserStory; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * Permanently deletes several {@link UserStory} aggregates of a project in one transaction. Only the + * candidate ids that actually belong to the project are resolved and deleted; ids that are unknown or + * live in another project/tenant are silently skipped (best-effort, never an error), so the returned + * count is the number of stories actually deleted. Each deletion cascades to the story's acceptance + * criteria via {@code orphanRemoval}. + *

              + * Local delete only: it does NOT touch any external tracker (e.g. Jira) issue a story was exported to. + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class BatchDeleteUserStoriesCommandHandler { + + private final UserStoryRepository stories; + + /** @return the number of stories actually deleted (candidate ids not in the project are skipped). */ + @Transactional + public int handle(BatchDeleteUserStoriesCommand command) { + List found = stories.findAllByProjectIdAndIdIn(command.projectId(), command.storyIds()); + found.forEach(stories::delete); + log.info("Batch-deleted {} of {} requested user stories for project {}", + found.size(), command.storyIds().size(), command.projectId()); + return found.size(); + } +} diff --git a/src/main/java/com/kntro/reqsai/discovery/application/handler/DeleteUserStoryCommandHandler.java b/src/main/java/com/kntro/reqsai/discovery/application/handler/DeleteUserStoryCommandHandler.java new file mode 100644 index 00000000..7a620540 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/discovery/application/handler/DeleteUserStoryCommandHandler.java @@ -0,0 +1,36 @@ +package com.kntro.reqsai.discovery.application.handler; + +import com.kntro.reqsai.discovery.application.command.DeleteUserStoryCommand; +import com.kntro.reqsai.discovery.application.port.UserStoryRepository; +import com.kntro.reqsai.discovery.domain.exception.DiscoveryExceptions; +import com.kntro.reqsai.discovery.domain.model.UserStory; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * Permanently deletes a single {@link UserStory} of the current tenant. The story is scope-checked + * against the project in a single lookup (404 when it does not exist in the project), mirroring the + * update path. Deletion is a hard delete (consistent with document deletion): removing the aggregate + * cascades to its acceptance criteria via {@code orphanRemoval}. + *

              + * This is a Reqs-AI-local delete only. It does NOT touch any external tracker (e.g. Jira) issue the + * story was previously exported to — the remote issue, if any, is left untouched. + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class DeleteUserStoryCommandHandler { + + private final UserStoryRepository stories; + + @Transactional + public void handle(DeleteUserStoryCommand command) { + UserStory story = stories.findByIdAndProjectId(command.storyId(), command.projectId()) + .orElseThrow(() -> DiscoveryExceptions.userStoryNotFound(command.storyId())); + + stories.delete(story); + log.info("User story {} deleted for project {}", command.storyId(), command.projectId()); + } +} diff --git a/src/main/java/com/kntro/reqsai/discovery/application/port/UserStoryRepository.java b/src/main/java/com/kntro/reqsai/discovery/application/port/UserStoryRepository.java index 77f8afb2..d8e8cb58 100644 --- a/src/main/java/com/kntro/reqsai/discovery/application/port/UserStoryRepository.java +++ b/src/main/java/com/kntro/reqsai/discovery/application/port/UserStoryRepository.java @@ -37,6 +37,20 @@ public interface UserStoryRepository { Page findAllBySessionId(UUID sessionId, Pageable pageable); + /** + * Returns the stories of {@code projectId} whose id is in {@code storyIds}, in an arbitrary order. + * Used by the batch delete to resolve the candidate ids to managed aggregates: ids not belonging to + * the project simply do not appear in the result (silently skipped). + */ + List findAllByProjectIdAndIdIn(UUID projectId, List storyIds); + + /** + * Permanently deletes the story (hard delete, mirroring document deletion). Removing the aggregate + * cascades to its acceptance criteria via {@code orphanRemoval}. This is a local delete only: it does + * not touch any external tracker (e.g. Jira) issue the story was exported to. + */ + void delete(UserStory story); + void deleteAllBySessionId(UUID sessionId); /** diff --git a/src/main/java/com/kntro/reqsai/discovery/infrastructure/persistence/adapters/UserStoryRepositoryAdapter.java b/src/main/java/com/kntro/reqsai/discovery/infrastructure/persistence/adapters/UserStoryRepositoryAdapter.java index d9134a6b..d4154400 100644 --- a/src/main/java/com/kntro/reqsai/discovery/infrastructure/persistence/adapters/UserStoryRepositoryAdapter.java +++ b/src/main/java/com/kntro/reqsai/discovery/infrastructure/persistence/adapters/UserStoryRepositoryAdapter.java @@ -54,6 +54,16 @@ public Page findAllBySessionId(UUID sessionId, Pageable pageable) { return jpa.findAllBySessionId(sessionId, pageable); } + @Override + public List findAllByProjectIdAndIdIn(UUID projectId, List storyIds) { + return jpa.findAllByProjectIdAndIdIn(projectId, storyIds); + } + + @Override + public void delete(UserStory story) { + jpa.delete(story); + } + @Override public void deleteAllBySessionId(UUID sessionId) { jpa.deleteAllBySessionId(sessionId); diff --git a/src/main/java/com/kntro/reqsai/discovery/infrastructure/persistence/repositories/UserStoryJpaRepository.java b/src/main/java/com/kntro/reqsai/discovery/infrastructure/persistence/repositories/UserStoryJpaRepository.java index 518aa183..f535858e 100644 --- a/src/main/java/com/kntro/reqsai/discovery/infrastructure/persistence/repositories/UserStoryJpaRepository.java +++ b/src/main/java/com/kntro/reqsai/discovery/infrastructure/persistence/repositories/UserStoryJpaRepository.java @@ -26,6 +26,9 @@ public interface UserStoryJpaRepository extends JpaRepository, Optional findByIdAndProjectId(UUID id, UUID projectId); + /** Stories of the project whose id is in the given collection (ids in other projects are excluded). */ + List findAllByProjectIdAndIdIn(UUID projectId, List ids); + /** Stories persisted without an embedding (provider down/failed at write time), oldest first. */ List findAllByProjectIdAndEmbeddingIsNull(UUID projectId, Pageable pageable); diff --git a/src/test/java/com/kntro/reqsai/discovery/application/handler/BatchDeleteUserStoriesCommandHandlerTest.java b/src/test/java/com/kntro/reqsai/discovery/application/handler/BatchDeleteUserStoriesCommandHandlerTest.java new file mode 100644 index 00000000..19dd4c77 --- /dev/null +++ b/src/test/java/com/kntro/reqsai/discovery/application/handler/BatchDeleteUserStoriesCommandHandlerTest.java @@ -0,0 +1,71 @@ +package com.kntro.reqsai.discovery.application.handler; + +import com.kntro.reqsai.discovery.application.command.BatchDeleteUserStoriesCommand; +import com.kntro.reqsai.discovery.application.port.UserStoryRepository; +import com.kntro.reqsai.discovery.domain.model.UserStory; +import com.kntro.reqsai.discovery.mothers.UserStoryMother; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link BatchDeleteUserStoriesCommandHandler}: it deletes only the candidate ids that + * belong to the project, silently skips the rest, and returns the number actually deleted. + */ +@Tag("unit") +@DisplayName("Application: Batch Delete User Stories") +@ExtendWith(MockitoExtension.class) +class BatchDeleteUserStoriesCommandHandlerTest { + + @Mock + private UserStoryRepository stories; + @InjectMocks + private BatchDeleteUserStoriesCommandHandler handler; + + @Test + @DisplayName("deletes only the stories found in the project and returns the deleted count") + void deletes_found_and_skips_missing() { + UUID projectId = UUID.randomUUID(); + UserStory a = UserStoryMother.draft().withProjectId(projectId).build(); + UserStory b = UserStoryMother.draft().withProjectId(projectId).build(); + UUID missing = UUID.randomUUID(); + List requested = List.of(a.getId(), b.getId(), missing); + + // the repository only returns the two ids that belong to the project; the missing id is skipped + when(stories.findAllByProjectIdAndIdIn(projectId, requested)).thenReturn(List.of(a, b)); + + int deleted = handler.handle(new BatchDeleteUserStoriesCommand(projectId, requested)); + + assertThat(deleted).isEqualTo(2); + verify(stories).delete(a); + verify(stories).delete(b); + verify(stories, times(2)).delete(any()); + } + + @Test + @DisplayName("returns zero and deletes nothing when none of the ids are in the project") + void deletes_nothing_when_all_missing() { + UUID projectId = UUID.randomUUID(); + List requested = List.of(UUID.randomUUID(), UUID.randomUUID()); + when(stories.findAllByProjectIdAndIdIn(projectId, requested)).thenReturn(List.of()); + + int deleted = handler.handle(new BatchDeleteUserStoriesCommand(projectId, requested)); + + assertThat(deleted).isZero(); + verify(stories, never()).delete(any()); + } +} diff --git a/src/test/java/com/kntro/reqsai/discovery/application/handler/DeleteUserStoryCommandHandlerTest.java b/src/test/java/com/kntro/reqsai/discovery/application/handler/DeleteUserStoryCommandHandlerTest.java new file mode 100644 index 00000000..f359afef --- /dev/null +++ b/src/test/java/com/kntro/reqsai/discovery/application/handler/DeleteUserStoryCommandHandlerTest.java @@ -0,0 +1,70 @@ +package com.kntro.reqsai.discovery.application.handler; + +import com.kntro.reqsai.discovery.application.command.DeleteUserStoryCommand; +import com.kntro.reqsai.discovery.application.port.UserStoryRepository; +import com.kntro.reqsai.discovery.domain.exception.DiscoveryError; +import com.kntro.reqsai.discovery.domain.model.UserStory; +import com.kntro.reqsai.discovery.mothers.UserStoryMother; +import com.kntro.reqsai.shared.domain.exception.EntityNotFoundException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link DeleteUserStoryCommandHandler} with a mocked repository. Deleting a story is a + * hard delete of the aggregate (its acceptance criteria go with it via cascade); a story that is not in + * the project surfaces as a 404. + */ +@Tag("unit") +@DisplayName("Application: Delete User Story") +@ExtendWith(MockitoExtension.class) +class DeleteUserStoryCommandHandlerTest { + + @Mock + private UserStoryRepository stories; + @InjectMocks + private DeleteUserStoryCommandHandler handler; + + @Test + @DisplayName("deletes the scoped story (acceptance criteria cascade with the aggregate)") + void deletes_scoped_story() { + UUID projectId = UUID.randomUUID(); + UserStory story = UserStoryMother.draft().withProjectId(projectId).build(); + story.addAcceptanceCriterion("scenario", "given", "when", "then"); + when(stories.findByIdAndProjectId(story.getId(), projectId)).thenReturn(Optional.of(story)); + + handler.handle(new DeleteUserStoryCommand(projectId, story.getId())); + + // deleting the aggregate cascades to its acceptance criteria (orphanRemoval on the collection) + assertThat(story.getAcceptanceCriteria()).hasSize(1); + verify(stories).delete(story); + } + + @Test + @DisplayName("throws 404 when the story does not exist in the project; nothing is deleted") + void throws_not_found_when_missing_in_project() { + UUID projectId = UUID.randomUUID(); + UUID storyId = UUID.randomUUID(); + when(stories.findByIdAndProjectId(storyId, projectId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> handler.handle(new DeleteUserStoryCommand(projectId, storyId))) + .isInstanceOf(EntityNotFoundException.class) + .satisfies(ex -> assertThat(((EntityNotFoundException) ex).error()) + .isEqualTo(DiscoveryError.USER_STORY_NOT_FOUND)); + verify(stories, never()).delete(any()); + } +} From 2800a3d0d07c8b440e5c56d16ff4f8416f431746 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Thu, 9 Jul 2026 22:53:03 -0500 Subject: [PATCH 64/72] feat(discovery): expose story delete and batch-delete endpoints --- .../ProjectStoryControllerImpl.java | 20 ++++++ .../BatchDeleteUserStoriesRequest.java | 19 ++++++ .../BatchDeleteUserStoriesResponse.java | 13 ++++ .../request/UserStoryRequestMapper.java | 11 ++++ .../rest/swagger/ProjectStoryController.java | 44 +++++++++++++ ...DiscoveryAccessControlIntegrationTest.java | 66 +++++++++++++++++++ 6 files changed, 173 insertions(+) create mode 100644 src/main/java/com/kntro/reqsai/discovery/interfaces/rest/dto/request/BatchDeleteUserStoriesRequest.java create mode 100644 src/main/java/com/kntro/reqsai/discovery/interfaces/rest/dto/response/BatchDeleteUserStoriesResponse.java diff --git a/src/main/java/com/kntro/reqsai/discovery/interfaces/rest/controllers/ProjectStoryControllerImpl.java b/src/main/java/com/kntro/reqsai/discovery/interfaces/rest/controllers/ProjectStoryControllerImpl.java index c82c9546..dbd40d7b 100644 --- a/src/main/java/com/kntro/reqsai/discovery/interfaces/rest/controllers/ProjectStoryControllerImpl.java +++ b/src/main/java/com/kntro/reqsai/discovery/interfaces/rest/controllers/ProjectStoryControllerImpl.java @@ -1,6 +1,8 @@ package com.kntro.reqsai.discovery.interfaces.rest.controllers; +import com.kntro.reqsai.discovery.application.handler.BatchDeleteUserStoriesCommandHandler; import com.kntro.reqsai.discovery.application.handler.CreateUserStoryCommandHandler; +import com.kntro.reqsai.discovery.application.handler.DeleteUserStoryCommandHandler; import com.kntro.reqsai.discovery.application.handler.GetProjectStoryQueryHandler; import com.kntro.reqsai.discovery.application.handler.ListProjectStoriesQueryHandler; import com.kntro.reqsai.discovery.application.handler.UpdateUserStoryCommandHandler; @@ -10,8 +12,10 @@ import com.kntro.reqsai.discovery.domain.model.Priority; import com.kntro.reqsai.discovery.domain.model.StoryStatus; import com.kntro.reqsai.discovery.domain.model.UserStory; +import com.kntro.reqsai.discovery.interfaces.rest.dto.request.BatchDeleteUserStoriesRequest; import com.kntro.reqsai.discovery.interfaces.rest.dto.request.CreateUserStoryRequest; import com.kntro.reqsai.discovery.interfaces.rest.dto.request.UpdateUserStoryRequest; +import com.kntro.reqsai.discovery.interfaces.rest.dto.response.BatchDeleteUserStoriesResponse; import com.kntro.reqsai.discovery.interfaces.rest.dto.response.UserStoryResponse; import com.kntro.reqsai.discovery.interfaces.rest.mappers.request.UserStoryRequestMapper; import com.kntro.reqsai.discovery.interfaces.rest.mappers.response.UserStoryResponseMapper; @@ -37,6 +41,8 @@ public class ProjectStoryControllerImpl implements ProjectStoryController { private final GetProjectStoryQueryHandler getUserStory; private final ListProjectStoriesQueryHandler listUserStories; private final UpdateUserStoryCommandHandler updateUserStory; + private final DeleteUserStoryCommandHandler deleteUserStory; + private final BatchDeleteUserStoriesCommandHandler batchDeleteUserStories; @Override @PreAuthorize("@authz.projectPermission(#projectId, 'STORY_WRITE', authentication)") @@ -82,6 +88,20 @@ public ResponseEntity update(UUID projectId, UUID storyId, Up return ResponseEntity.ok(UserStoryResponseMapper.toResponse(story)); } + @Override + @PreAuthorize("@authz.projectPermission(#projectId, 'STORY_DELETE', authentication)") + public ResponseEntity delete(UUID projectId, UUID storyId) { + deleteUserStory.handle(UserStoryRequestMapper.toDeleteCommand(projectId, storyId)); + return ResponseEntity.noContent().build(); + } + + @Override + @PreAuthorize("@authz.projectPermission(#projectId, 'STORY_DELETE', authentication)") + public ResponseEntity batchDelete(UUID projectId, BatchDeleteUserStoriesRequest request) { + int deleted = batchDeleteUserStories.handle(UserStoryRequestMapper.toBatchDeleteCommand(projectId, request)); + return ResponseEntity.ok(new BatchDeleteUserStoriesResponse(deleted)); + } + /** * Parses an optional enum query param, treating {@code null}/blank as "no filter". An unrecognized * value is a client error → 400, rather than being silently dropped (which would return an diff --git a/src/main/java/com/kntro/reqsai/discovery/interfaces/rest/dto/request/BatchDeleteUserStoriesRequest.java b/src/main/java/com/kntro/reqsai/discovery/interfaces/rest/dto/request/BatchDeleteUserStoriesRequest.java new file mode 100644 index 00000000..1276f314 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/discovery/interfaces/rest/dto/request/BatchDeleteUserStoriesRequest.java @@ -0,0 +1,19 @@ +package com.kntro.reqsai.discovery.interfaces.rest.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Size; + +import java.util.List; +import java.util.UUID; + +/** + * Request body to delete several user stories of a project in one call. Ids not belonging to the + * project are silently skipped; the response reports how many were actually deleted. + */ +@Schema(description = "Request body to delete several user stories in one call") +public record BatchDeleteUserStoriesRequest( + @Schema(description = "Ids of the stories to delete (ids not in the project are skipped)", + example = "[\"019756a0-1234-7abc-8def-000000000010\",\"019756a0-1234-7abc-8def-000000000011\"]") + @NotEmpty @Size(max = 200) List storyIds +) {} diff --git a/src/main/java/com/kntro/reqsai/discovery/interfaces/rest/dto/response/BatchDeleteUserStoriesResponse.java b/src/main/java/com/kntro/reqsai/discovery/interfaces/rest/dto/response/BatchDeleteUserStoriesResponse.java new file mode 100644 index 00000000..fe9a0400 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/discovery/interfaces/rest/dto/response/BatchDeleteUserStoriesResponse.java @@ -0,0 +1,13 @@ +package com.kntro.reqsai.discovery.interfaces.rest.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * Result of a batch user-story delete: how many of the requested stories were actually deleted + * (candidate ids not found in the project are skipped and not counted). + */ +@Schema(description = "Result of a batch user-story delete") +public record BatchDeleteUserStoriesResponse( + @Schema(description = "Number of stories actually deleted", example = "3") + int deleted +) {} diff --git a/src/main/java/com/kntro/reqsai/discovery/interfaces/rest/mappers/request/UserStoryRequestMapper.java b/src/main/java/com/kntro/reqsai/discovery/interfaces/rest/mappers/request/UserStoryRequestMapper.java index daa4b27c..68185bef 100644 --- a/src/main/java/com/kntro/reqsai/discovery/interfaces/rest/mappers/request/UserStoryRequestMapper.java +++ b/src/main/java/com/kntro/reqsai/discovery/interfaces/rest/mappers/request/UserStoryRequestMapper.java @@ -1,7 +1,10 @@ package com.kntro.reqsai.discovery.interfaces.rest.mappers.request; +import com.kntro.reqsai.discovery.application.command.BatchDeleteUserStoriesCommand; import com.kntro.reqsai.discovery.application.command.CreateUserStoryCommand; +import com.kntro.reqsai.discovery.application.command.DeleteUserStoryCommand; import com.kntro.reqsai.discovery.application.command.UpdateUserStoryCommand; +import com.kntro.reqsai.discovery.interfaces.rest.dto.request.BatchDeleteUserStoriesRequest; import com.kntro.reqsai.discovery.interfaces.rest.dto.request.CreateUserStoryRequest; import com.kntro.reqsai.discovery.interfaces.rest.dto.request.UpdateUserStoryRequest; @@ -21,4 +24,12 @@ public static CreateUserStoryCommand toCommand(UUID projectId, CreateUserStoryRe public static UpdateUserStoryCommand toUpdateCommand(UUID projectId, UUID storyId, UpdateUserStoryRequest request) { return new UpdateUserStoryCommand(projectId, storyId, request.title(), request.role(), request.action(), request.benefit(), request.priority(), request.storyPoints()); } + + public static DeleteUserStoryCommand toDeleteCommand(UUID projectId, UUID storyId) { + return new DeleteUserStoryCommand(projectId, storyId); + } + + public static BatchDeleteUserStoriesCommand toBatchDeleteCommand(UUID projectId, BatchDeleteUserStoriesRequest request) { + return new BatchDeleteUserStoriesCommand(projectId, request.storyIds()); + } } diff --git a/src/main/java/com/kntro/reqsai/discovery/interfaces/rest/swagger/ProjectStoryController.java b/src/main/java/com/kntro/reqsai/discovery/interfaces/rest/swagger/ProjectStoryController.java index dd48a24b..e8f5a758 100644 --- a/src/main/java/com/kntro/reqsai/discovery/interfaces/rest/swagger/ProjectStoryController.java +++ b/src/main/java/com/kntro/reqsai/discovery/interfaces/rest/swagger/ProjectStoryController.java @@ -1,7 +1,9 @@ package com.kntro.reqsai.discovery.interfaces.rest.swagger; +import com.kntro.reqsai.discovery.interfaces.rest.dto.request.BatchDeleteUserStoriesRequest; import com.kntro.reqsai.discovery.interfaces.rest.dto.request.CreateUserStoryRequest; import com.kntro.reqsai.discovery.interfaces.rest.dto.request.UpdateUserStoryRequest; +import com.kntro.reqsai.discovery.interfaces.rest.dto.response.BatchDeleteUserStoriesResponse; import com.kntro.reqsai.discovery.interfaces.rest.dto.response.UserStoryResponse; import com.kntro.reqsai.shared.interfaces.pagination.PageResponse; import com.kntro.reqsai.shared.infrastructure.configuration.ApiVersioning; @@ -21,6 +23,7 @@ import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -157,4 +160,45 @@ ResponseEntity update( @Parameter(description = "Story to update", required = true) @PathVariable UUID storyId, @Valid @RequestBody UpdateUserStoryRequest request); + + @Operation( + summary = "Delete a user story", + description = """ + Permanently deletes a single user story of the given project, scoped to the \ + authenticated tenant. The story's acceptance criteria are removed with it. This is a \ + Reqs-AI-local delete only: it does NOT touch any external tracker (e.g. Jira) issue \ + the story was previously exported to.""") + @ApiResponse(responseCode = "204", description = "Story deleted") + @ApiResponseNotFound + @ApiStandardErrorResponses + @SecurityRequirement(name = OpenApiConfiguration.BEARER_SCHEME) + @DeleteMapping(path = "/{storyId}", version = ApiVersioning.V1) + ResponseEntity delete( + @Parameter(description = "Project the story belongs to", required = true) + @PathVariable UUID projectId, + @Parameter(description = "Story to delete", required = true) + @PathVariable UUID storyId); + + @Operation( + summary = "Delete several user stories in one call", + description = """ + Permanently deletes the given stories of the project, scoped to the authenticated \ + tenant, and returns how many were actually deleted. Ids not found in the project are \ + silently skipped (never an error). Each deleted story's acceptance criteria are \ + removed with it. Local delete only: it does NOT touch any external tracker (e.g. \ + Jira) issue a story was exported to.""") + @ApiResponse( + responseCode = "200", + description = "Stories deleted; body reports the deleted count", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = BatchDeleteUserStoriesResponse.class), + examples = @ExampleObject(value = "{ \"deleted\": 3 }"))) + @ApiResponseBadRequest + @ApiStandardErrorResponses + @SecurityRequirement(name = OpenApiConfiguration.BEARER_SCHEME) + @PostMapping(path = "/batch-delete", version = ApiVersioning.V1) + ResponseEntity batchDelete( + @Parameter(description = "Project the stories belong to", required = true) + @PathVariable UUID projectId, + @Valid @RequestBody BatchDeleteUserStoriesRequest request); } diff --git a/src/test/java/com/kntro/reqsai/discovery/interfaces/rest/DiscoveryAccessControlIntegrationTest.java b/src/test/java/com/kntro/reqsai/discovery/interfaces/rest/DiscoveryAccessControlIntegrationTest.java index 0ef5d480..57a3d4d1 100644 --- a/src/test/java/com/kntro/reqsai/discovery/interfaces/rest/DiscoveryAccessControlIntegrationTest.java +++ b/src/test/java/com/kntro/reqsai/discovery/interfaces/rest/DiscoveryAccessControlIntegrationTest.java @@ -106,8 +106,74 @@ void discovery_endpoints_enforce_project_permissions() { .isEqualTo(HttpStatus.NOT_FOUND); } + @Test + @DisplayName("story delete + batch-delete require STORY_DELETE: a writer without it gets 403; with it 204/200") + void story_delete_endpoints_enforce_story_delete_permission() { + String suffix = UUID.randomUUID().toString().substring(0, 8); + String slug = "acme-" + suffix; + String schema = "tenant_" + slug; + UUID orgId = createOrganizationAndReturnId(suffix, slug); + + createMember(orgId, Map.of( + "userId", READER_USER_ID, "email", "writer@example.com", "displayName", "Writer", "role", "MEMBER")); + UUID writerId = memberId(orgId, "writer@example.com"); + + UUID projectId = createProjectAndReturnId(orgId, slug); + // A writer role that can create/edit but NOT delete stories. + String writerRoleId = createRoleAndReturnId(orgId, projectId, "Story Writer", + List.of("STORY_READ", "STORY_WRITE"), schema); + assignMember(orgId, projectId, writerId.toString(), writerRoleId); + + // Owner seeds two distinct stories (owner bypasses the gates). + UUID story1 = createStory(orgId, projectId, "Bulk import suppliers via CSV upload"); + UUID story2 = createStory(orgId, projectId, "Export the monthly compliance audit report"); + + // Writer lacks STORY_DELETE -> single delete and batch-delete are both forbidden. + assertThat(delete(READER_USER_ID, orgId, "/api/projects/" + projectId + "/stories/" + story1).getStatusCode()) + .isEqualTo(HttpStatus.FORBIDDEN); + assertThat(post(READER_USER_ID, orgId, "/api/projects/" + projectId + "/stories/batch-delete", + Map.of("storyIds", List.of(story1, story2))).getStatusCode()) + .isEqualTo(HttpStatus.FORBIDDEN); + + // Grant STORY_DELETE by updating the role. + put(OWNER_USER_ID, orgId, + "/api/organizations/" + orgId + "/projects/" + projectId + "/roles/" + writerRoleId, + Map.of("name", "Story Writer", "permissions", List.of("STORY_READ", "STORY_WRITE", "STORY_DELETE"))); + + // Now the single delete succeeds (204) and the batch-delete of the remaining story returns 200 {deleted:1}. + assertThat(delete(READER_USER_ID, orgId, "/api/projects/" + projectId + "/stories/" + story1).getStatusCode()) + .isEqualTo(HttpStatus.NO_CONTENT); + ResponseEntity batch = post(READER_USER_ID, orgId, + "/api/projects/" + projectId + "/stories/batch-delete", + Map.of("storyIds", List.of(story2, UUID.randomUUID()))); + assertThat(batch.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(batch.getBody()).contains("\"deleted\":1"); + } + // ----- helpers ----- + private UUID createStory(UUID orgId, UUID projectId, String action) { + ResponseEntity res = post(OWNER_USER_ID, orgId, "/api/projects/" + projectId + "/stories", + Map.of("title", action, "role", "user", "action", action, "benefit", "access", "priority", "HIGH")); + assertThat(res.getStatusCode()).isEqualTo(HttpStatus.CREATED); + return UUID.fromString(res.getBody().split("\"id\":\"")[1].split("\"")[0]); + } + + private ResponseEntity delete(String userId, UUID orgId, String uri) { + return client().method(org.springframework.http.HttpMethod.DELETE).uri(uri) + .header("Authorization", TestJwtFactory.bearer(userId, orgId.toString(), "ROLE_USER")) + .header("Api-Version", "1") + .exchange((req, res) -> ResponseEntity.status(res.getStatusCode()).body(res.bodyTo(String.class))); + } + + private ResponseEntity put(String userId, UUID orgId, String uri, Map body) { + return client().put().uri(uri) + .header("Authorization", TestJwtFactory.bearer(userId, orgId.toString(), "ROLE_USER")) + .header("Api-Version", "1") + .contentType(MediaType.APPLICATION_JSON).body(body) + .exchange((req, res) -> ResponseEntity.status(res.getStatusCode()).body(res.bodyTo(String.class))); + } + private ResponseEntity get(String userId, UUID orgId, String uri) { return client().get().uri(uri) .header("Authorization", TestJwtFactory.bearer(userId, orgId.toString(), "ROLE_USER")) From 66db1c74bf9b504c936ca9e23000f69bd9554fb1 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Thu, 9 Jul 2026 22:53:15 -0500 Subject: [PATCH 65/72] feat(gateway): let Jira push-all target an optional story selection --- .../command/PushAllStoriesCommand.java | 15 ++- .../handler/PushAllStoriesCommandHandler.java | 26 ++++- .../port/IntegrationJobLauncher.java | 7 +- .../IntegrationBatchJobsConfiguration.java | 17 +++- .../batch/IntegrationJobLauncherAdapter.java | 15 +-- .../batch/IntegrationJobParameters.java | 37 ++++++- .../ProjectIntegrationControllerImpl.java | 7 +- .../dto/request/PushAllStoriesRequest.java | 19 ++++ .../swagger/ProjectIntegrationController.java | 15 +-- .../PushAllStoriesCommandHandlerTest.java | 29 +++++- .../IntegrationJobLauncherAdapterTest.java | 21 +++- .../batch/JiraPushAllReaderTest.java | 99 +++++++++++++++++++ .../JiraIntegrationPushIntegrationTest.java | 68 +++++++++++++ 13 files changed, 343 insertions(+), 32 deletions(-) create mode 100644 src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/request/PushAllStoriesRequest.java create mode 100644 src/test/java/com/kntro/reqsai/gateway/infrastructure/batch/JiraPushAllReaderTest.java diff --git a/src/main/java/com/kntro/reqsai/gateway/application/command/PushAllStoriesCommand.java b/src/main/java/com/kntro/reqsai/gateway/application/command/PushAllStoriesCommand.java index 48f3827c..a65e3621 100644 --- a/src/main/java/com/kntro/reqsai/gateway/application/command/PushAllStoriesCommand.java +++ b/src/main/java/com/kntro/reqsai/gateway/application/command/PushAllStoriesCommand.java @@ -1,6 +1,17 @@ package com.kntro.reqsai.gateway.application.command; +import org.jspecify.annotations.Nullable; + +import java.util.List; import java.util.UUID; -/** Push every story of a project to the project's configured Jira target (per-story failures captured). */ -public record PushAllStoriesCommand(UUID projectId, UUID requestedBy) {} +/** + * Push stories of a project to the project's configured Jira target (per-story failures captured). + * {@code storyIds} optionally restricts the push to the given stories; {@code null}/empty means every + * eligible story (the unrestricted, original behaviour). Ids not in the project are ignored. + * + * @param projectId project whose stories are pushed + * @param storyIds the specific stories to push; {@code null}/empty means all eligible stories + * @param requestedBy caller id (authorization already enforced at the controller) + */ +public record PushAllStoriesCommand(UUID projectId, @Nullable List storyIds, UUID requestedBy) {} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/handler/PushAllStoriesCommandHandler.java b/src/main/java/com/kntro/reqsai/gateway/application/handler/PushAllStoriesCommandHandler.java index 3941099f..95344463 100644 --- a/src/main/java/com/kntro/reqsai/gateway/application/handler/PushAllStoriesCommandHandler.java +++ b/src/main/java/com/kntro/reqsai/gateway/application/handler/PushAllStoriesCommandHandler.java @@ -1,6 +1,7 @@ package com.kntro.reqsai.gateway.application.handler; import com.kntro.reqsai.discovery.api.DiscoveryStoryReadPort; +import com.kntro.reqsai.discovery.api.StoryView; import com.kntro.reqsai.gateway.application.command.PushAllStoriesCommand; import com.kntro.reqsai.gateway.application.port.IntegrationJobLauncher; import com.kntro.reqsai.gateway.application.port.ProjectIntegrationTargetRepository; @@ -11,13 +12,19 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.UUID; + /** * Accepts a push-all request as an asynchronous background job: validates the * target exists (409 {@code INTEGRATION_TARGET_NOT_CONFIGURED}), persists a RUNNING * {@code integration_sync_jobs} row (409 {@code INTEGRATION_JOB_ALREADY_RUNNING} when one is * already running), hands execution to the {@link IntegrationJobLauncher} and returns the job * snapshot for the 202 response. The known story count seeds {@code total} immediately so the - * progress banner can render a meaningful bar from the first frame. Deliberately not + * progress banner can render a meaningful bar from the first frame — when the command carries a + * story-id selection the seed is the count of selected stories that actually exist in the project + * (ids not in the project are ignored, matching the reader's filter). Deliberately not * {@code @Transactional}: the job row must be committed before the launch. */ @Component @@ -33,10 +40,23 @@ public IntegrationSyncJob handle(PushAllStoriesCommand command) { targets.findByProjectId(command.projectId()) .orElseThrow(() -> IntegrationsExceptions.targetNotConfigured(command.projectId())); - int knownTotal = stories.listStories(command.projectId()).size(); + int knownTotal = countEligible(command); IntegrationSyncJob job = starter.start( command.projectId(), IntegrationSyncJobType.PUSH_ALL, knownTotal, command.requestedBy()); - launcher.launchPushAll(job.getId(), command.projectId()); + launcher.launchPushAll(job.getId(), command.projectId(), command.storyIds()); return job; } + + /** + * Number of stories the run will actually push: every project story when unrestricted, otherwise + * the selected ids that exist in the project (unknown ids are ignored — same rule as the reader). + */ + private int countEligible(PushAllStoriesCommand command) { + java.util.List all = stories.listStories(command.projectId()); + if (command.storyIds() == null || command.storyIds().isEmpty()) { + return all.size(); + } + Set selected = new LinkedHashSet<>(command.storyIds()); + return (int) all.stream().filter(story -> selected.contains(story.storyId())).count(); + } } diff --git a/src/main/java/com/kntro/reqsai/gateway/application/port/IntegrationJobLauncher.java b/src/main/java/com/kntro/reqsai/gateway/application/port/IntegrationJobLauncher.java index 901d2905..27bb5da3 100644 --- a/src/main/java/com/kntro/reqsai/gateway/application/port/IntegrationJobLauncher.java +++ b/src/main/java/com/kntro/reqsai/gateway/application/port/IntegrationJobLauncher.java @@ -17,6 +17,9 @@ public interface IntegrationJobLauncher { /** Starts the Jira import run for an already-persisted RUNNING job row. */ void launchImport(UUID jobId, UUID projectId, @Nullable List issueKeys); - /** Starts the push-all run for an already-persisted RUNNING job row. */ - void launchPushAll(UUID jobId, UUID projectId); + /** + * Starts the push-all run for an already-persisted RUNNING job row. {@code storyIds} optionally + * restricts the push to the given stories; {@code null}/empty pushes every eligible story. + */ + void launchPushAll(UUID jobId, UUID projectId, @Nullable List storyIds); } diff --git a/src/main/java/com/kntro/reqsai/gateway/infrastructure/batch/IntegrationBatchJobsConfiguration.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/batch/IntegrationBatchJobsConfiguration.java index 694c748a..7f351d45 100644 --- a/src/main/java/com/kntro/reqsai/gateway/infrastructure/batch/IntegrationBatchJobsConfiguration.java +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/batch/IntegrationBatchJobsConfiguration.java @@ -165,18 +165,27 @@ public Step jiraPushAllStep(JobRepository jobRepository, .build(); } - /** Resolves the push work list (every project story) and fixes the projection's {@code total}. */ + /** + * Resolves the push work list and fixes the projection's {@code total}. Pushes every project story + * unless a story-id selection was carried on the job, in which case the list is filtered to the + * selected ids (original ordering preserved; ids not in the project are ignored). The {@code total} + * reflects the filtered count. + */ @Bean @StepScope public ListItemReader jiraPushAllReader( @Value("#{jobParameters['" + IntegrationJobParameters.DOMAIN_JOB_ID + "']}") String domainJobId, @Value("#{jobParameters['" + IntegrationJobParameters.PROJECT_ID + "']}") String projectId, + @Value("#{jobParameters['" + IntegrationJobParameters.STORY_IDS + "']}") String storyIdsCsv, DiscoveryStoryReadPort stories, IntegrationSyncJobRepository jobs, IntegrationJobProgressNotifier progress) { - List all = stories.listStories(UUID.fromString(projectId)); - planTotal(jobs, progress, domainJobId, all.size()); - return new ListItemReader<>(all); + Set requested = IntegrationJobParameters.parseStoryIds(storyIdsCsv); + List selected = stories.listStories(UUID.fromString(projectId)).stream() + .filter(story -> requested.isEmpty() || requested.contains(story.storyId())) + .toList(); + planTotal(jobs, progress, domainJobId, selected.size()); + return new ListItemReader<>(selected); } @Bean diff --git a/src/main/java/com/kntro/reqsai/gateway/infrastructure/batch/IntegrationJobLauncherAdapter.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/batch/IntegrationJobLauncherAdapter.java index 019c6dcb..4b3ee03b 100644 --- a/src/main/java/com/kntro/reqsai/gateway/infrastructure/batch/IntegrationJobLauncherAdapter.java +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/batch/IntegrationJobLauncherAdapter.java @@ -53,23 +53,26 @@ public IntegrationJobLauncherAdapter( @Override public void launchImport(UUID jobId, UUID projectId, @Nullable List issueKeys) { - launch(jiraImportJob, jobId, projectId, IntegrationJobParameters.joinIssueKeys(issueKeys)); + launch(jiraImportJob, jobId, projectId, + IntegrationJobParameters.ISSUE_KEYS, IntegrationJobParameters.joinIssueKeys(issueKeys)); } @Override - public void launchPushAll(UUID jobId, UUID projectId) { - launch(jiraPushAllJob, jobId, projectId, null); + public void launchPushAll(UUID jobId, UUID projectId, @Nullable List storyIds) { + launch(jiraPushAllJob, jobId, projectId, + IntegrationJobParameters.STORY_IDS, IntegrationJobParameters.joinStoryIds(storyIds)); } - private void launch(Job batchJob, UUID jobId, UUID projectId, @Nullable String issueKeysCsv) { + private void launch(Job batchJob, UUID jobId, UUID projectId, + String selectionKey, @Nullable String selectionCsv) { TenantSnapshot tenant = TenantContext.capture(); JobParametersBuilder params = new JobParametersBuilder() .addString(IntegrationJobParameters.DOMAIN_JOB_ID, jobId.toString(), true) .addString(IntegrationJobParameters.PROJECT_ID, projectId.toString(), false) .addString(IntegrationJobParameters.TENANT_ID, tenant.tenantId(), false) .addString(IntegrationJobParameters.TENANT_SCHEMA, tenant.tenantSchema(), false); - if (issueKeysCsv != null) { - params.addString(IntegrationJobParameters.ISSUE_KEYS, issueKeysCsv, false); + if (selectionCsv != null) { + params.addString(selectionKey, selectionCsv, false); } try { jobOperator.start(batchJob, params.toJobParameters()); diff --git a/src/main/java/com/kntro/reqsai/gateway/infrastructure/batch/IntegrationJobParameters.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/batch/IntegrationJobParameters.java index 2e124b9e..25bc5c90 100644 --- a/src/main/java/com/kntro/reqsai/gateway/infrastructure/batch/IntegrationJobParameters.java +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/batch/IntegrationJobParameters.java @@ -1,7 +1,10 @@ package com.kntro.reqsai.gateway.infrastructure.batch; import java.util.LinkedHashSet; +import java.util.List; import java.util.Set; +import java.util.StringJoiner; +import java.util.UUID; /** * Job-parameter keys shared by the integration batch jobs. Spring Batch derives the @@ -10,7 +13,7 @@ * API-triggered run is a fresh JobInstance with exactly one JobExecution, and the batch metadata * links 1:1 back to the domain row. The remaining keys are non-identifying context the execution * needs: the tenant coordinates to restore ({@link #TENANT_ID}/{@link #TENANT_SCHEMA}), the project, - * and the optional issue-key selection. + * the optional issue-key selection (import) and the optional story-id selection (push-all). */ public final class IntegrationJobParameters { @@ -24,6 +27,9 @@ public final class IntegrationJobParameters { /** Comma-joined Jira issue keys to import; absent/blank means all eligible issues. */ public static final String ISSUE_KEYS = "issueKeys"; + /** Comma-joined story ids to push; absent/blank means all eligible stories. */ + public static final String STORY_IDS = "storyIds"; + private IntegrationJobParameters() { throw new UnsupportedOperationException("Utility class - do not instantiate"); } @@ -48,4 +54,33 @@ public static String joinIssueKeys(java.util.List issueKeys) { } return String.join(",", issueKeys); } + + /** + * Parses the comma-joined {@link #STORY_IDS} value into an ordered set of {@link UUID}s; an empty + * set means "no restriction" (push every eligible story). Blank or malformed tokens are ignored. + */ + public static Set parseStoryIds(String storyIdsCsv) { + Set ids = new LinkedHashSet<>(); + if (storyIdsCsv != null && !storyIdsCsv.isBlank()) { + for (String token : storyIdsCsv.split(",")) { + String trimmed = token.trim(); + if (!trimmed.isBlank()) { + ids.add(UUID.fromString(trimmed)); + } + } + } + return ids; + } + + /** Joins story ids for the {@link #STORY_IDS} parameter; {@code null} when unrestricted. */ + public static String joinStoryIds(List storyIds) { + if (storyIds == null || storyIds.isEmpty()) { + return null; + } + StringJoiner joiner = new StringJoiner(","); + for (UUID id : storyIds) { + joiner.add(id.toString()); + } + return joiner.toString(); + } } diff --git a/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/controllers/ProjectIntegrationControllerImpl.java b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/controllers/ProjectIntegrationControllerImpl.java index 66b59fcf..218665a8 100644 --- a/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/controllers/ProjectIntegrationControllerImpl.java +++ b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/controllers/ProjectIntegrationControllerImpl.java @@ -18,6 +18,7 @@ import com.kntro.reqsai.gateway.application.query.ListIntegrationJobsQuery; import com.kntro.reqsai.gateway.application.query.PreviewJiraImportQuery; import com.kntro.reqsai.gateway.interfaces.rest.dto.request.ImportJiraStoriesRequest; +import com.kntro.reqsai.gateway.interfaces.rest.dto.request.PushAllStoriesRequest; import com.kntro.reqsai.gateway.interfaces.rest.dto.request.SaveProjectTargetRequest; import com.kntro.reqsai.gateway.interfaces.rest.dto.response.IntegrationJobResponse; import com.kntro.reqsai.gateway.interfaces.rest.dto.response.JiraImportPreviewResponse; @@ -96,10 +97,12 @@ public ResponseEntity pushStory(UUID projectId, UUID sto @Override @PreAuthorize("@authz.projectPermission(#projectId, 'INTEGRATION_SYNC', authentication)") - public ResponseEntity pushAllStories(UUID projectId, Authentication authentication) { + public ResponseEntity pushAllStories( + UUID projectId, PushAllStoriesRequest request, Authentication authentication) { UUID requestedBy = UUID.fromString(authentication.getName()); + List storyIds = request == null ? null : request.storyIds(); return ResponseEntity.accepted().body(IntegrationResponseMapper.toResponse( - pushAllStories.handle(new PushAllStoriesCommand(projectId, requestedBy)))); + pushAllStories.handle(new PushAllStoriesCommand(projectId, storyIds, requestedBy)))); } @Override diff --git a/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/request/PushAllStoriesRequest.java b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/request/PushAllStoriesRequest.java new file mode 100644 index 00000000..ec871a43 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/request/PushAllStoriesRequest.java @@ -0,0 +1,19 @@ +package com.kntro.reqsai.gateway.interfaces.rest.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import org.jspecify.annotations.Nullable; + +import java.util.List; +import java.util.UUID; + +/** + * Optional request body for the Jira push-all. {@code storyIds} restricts the push to the given stories; + * omit the body or leave it empty to push every eligible story of the project (the original behaviour). + * Ids not belonging to the project are ignored. + */ +@Schema(description = "Optional request body to push a selection of stories to Jira") +public record PushAllStoriesRequest( + @Schema(description = "Specific story ids to push; omit/empty = all eligible stories", + example = "[\"019756a0-1234-7abc-8def-000000000010\"]") + @Nullable List storyIds +) {} diff --git a/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/swagger/ProjectIntegrationController.java b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/swagger/ProjectIntegrationController.java index 0775956c..8c7b2805 100644 --- a/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/swagger/ProjectIntegrationController.java +++ b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/swagger/ProjectIntegrationController.java @@ -1,6 +1,7 @@ package com.kntro.reqsai.gateway.interfaces.rest.swagger; import com.kntro.reqsai.gateway.interfaces.rest.dto.request.ImportJiraStoriesRequest; +import com.kntro.reqsai.gateway.interfaces.rest.dto.request.PushAllStoriesRequest; import com.kntro.reqsai.gateway.interfaces.rest.dto.request.SaveProjectTargetRequest; import com.kntro.reqsai.gateway.interfaces.rest.dto.response.IntegrationJobResponse; import com.kntro.reqsai.gateway.interfaces.rest.dto.response.JiraImportPreviewResponse; @@ -97,12 +98,13 @@ ResponseEntity pushStory( @Operation(summary = "Push all stories to Jira (async job)", description = """ - Starts a background job that pushes every project story to the Jira target and returns - 202 immediately with the job snapshot. Progress is broadcast on - /topic/projects/{projectId}/integration-jobs and queryable via the jobs endpoints. - Per-story failures are counted without aborting the job. 409 when no target is - configured (INTEGRATION_TARGET_NOT_CONFIGURED) or a push-all job is already running - (INTEGRATION_JOB_ALREADY_RUNNING).""") + Starts a background job that pushes project stories to the Jira target and returns + 202 immediately with the job snapshot. Body {storyIds?} restricts the push to the + given stories; omit/empty pushes every eligible story (ids not in the project are + ignored). Progress is broadcast on /topic/projects/{projectId}/integration-jobs and + queryable via the jobs endpoints. Per-story failures are counted without aborting the + job. 409 when no target is configured (INTEGRATION_TARGET_NOT_CONFIGURED) or a + push-all job is already running (INTEGRATION_JOB_ALREADY_RUNNING).""") @ApiResponse(responseCode = "202", description = "Job accepted and running", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = @Schema(implementation = IntegrationJobResponse.class))) @@ -112,6 +114,7 @@ ResponseEntity pushStory( @PostMapping(value = "/stories/push-all", version = ApiVersioning.V1) ResponseEntity pushAllStories( @Parameter(description = "Project UUID") @PathVariable UUID projectId, + @RequestBody(required = false) PushAllStoriesRequest request, Authentication authentication); @Operation(summary = "Preview a Jira import", diff --git a/src/test/java/com/kntro/reqsai/gateway/application/handler/PushAllStoriesCommandHandlerTest.java b/src/test/java/com/kntro/reqsai/gateway/application/handler/PushAllStoriesCommandHandlerTest.java index 54ad64f8..98e5d628 100644 --- a/src/test/java/com/kntro/reqsai/gateway/application/handler/PushAllStoriesCommandHandlerTest.java +++ b/src/test/java/com/kntro/reqsai/gateway/application/handler/PushAllStoriesCommandHandlerTest.java @@ -58,12 +58,33 @@ void starts_job_and_launches() { IntegrationSyncJob job = new IntegrationSyncJob(PROJECT, IntegrationSyncJobType.PUSH_ALL, 3, USER); when(starter.start(PROJECT, IntegrationSyncJobType.PUSH_ALL, 3, USER)).thenReturn(job); - IntegrationSyncJob result = handler.handle(new PushAllStoriesCommand(PROJECT, USER)); + IntegrationSyncJob result = handler.handle(new PushAllStoriesCommand(PROJECT, null, USER)); assertThat(result.getStatus()).isEqualTo(IntegrationSyncJobStatus.RUNNING); assertThat(result.getTotal()).isEqualTo(3); assertThat(result.getProcessed()).isZero(); - verify(launcher).launchPushAll(job.getId(), PROJECT); + verify(launcher).launchPushAll(job.getId(), PROJECT, null); + } + + @Test + @DisplayName("with a story-id selection, total counts only selected stories present in the project") + void starts_job_with_selection() { + StoryView a = story(); + StoryView b = story(); + StoryView c = story(); + UUID missing = UUID.randomUUID(); + List selection = List.of(a.storyId(), c.storyId(), missing); + + when(targets.findByProjectId(PROJECT)).thenReturn(Optional.of(mock(ProjectIntegrationTarget.class))); + when(stories.listStories(PROJECT)).thenReturn(List.of(a, b, c)); + IntegrationSyncJob job = new IntegrationSyncJob(PROJECT, IntegrationSyncJobType.PUSH_ALL, 2, USER); + when(starter.start(PROJECT, IntegrationSyncJobType.PUSH_ALL, 2, USER)).thenReturn(job); + + IntegrationSyncJob result = handler.handle(new PushAllStoriesCommand(PROJECT, selection, USER)); + + // only a and c exist in the project; the missing id is ignored + assertThat(result.getTotal()).isEqualTo(2); + verify(launcher).launchPushAll(job.getId(), PROJECT, selection); } @Test @@ -71,11 +92,11 @@ void starts_job_and_launches() { void no_target_conflicts() { when(targets.findByProjectId(PROJECT)).thenReturn(Optional.empty()); - assertThatThrownBy(() -> handler.handle(new PushAllStoriesCommand(PROJECT, USER))) + assertThatThrownBy(() -> handler.handle(new PushAllStoriesCommand(PROJECT, null, USER))) .isInstanceOf(DomainException.class) .satisfies(e -> assertThat(((DomainException) e).error().code()) .isEqualTo("INTEGRATION_TARGET_NOT_CONFIGURED")); - verify(launcher, never()).launchPushAll(any(), any()); + verify(launcher, never()).launchPushAll(any(), any(), any()); } private static StoryView story() { diff --git a/src/test/java/com/kntro/reqsai/gateway/infrastructure/batch/IntegrationJobLauncherAdapterTest.java b/src/test/java/com/kntro/reqsai/gateway/infrastructure/batch/IntegrationJobLauncherAdapterTest.java index 8b21152c..39ad1b6e 100644 --- a/src/test/java/com/kntro/reqsai/gateway/infrastructure/batch/IntegrationJobLauncherAdapterTest.java +++ b/src/test/java/com/kntro/reqsai/gateway/infrastructure/batch/IntegrationJobLauncherAdapterTest.java @@ -78,18 +78,35 @@ void captures_tenant_snapshot() throws Exception { } @Test - @DisplayName("launchPushAll omits issue keys and starts the push-all job") + @DisplayName("launchPushAll without a selection omits story ids and starts the push-all job") void launches_push_all() throws Exception { TenantContext.setCurrentTenant("org-1"); TenantContext.setCurrentSchema("tenant_acme"); - adapter().launchPushAll(JOB_ID, PROJECT); + adapter().launchPushAll(JOB_ID, PROJECT, null); ArgumentCaptor params = ArgumentCaptor.forClass(JobParameters.class); verify(jobOperator).start(eq(jiraPushAllJob), params.capture()); + assertThat(params.getValue().getParameter(IntegrationJobParameters.STORY_IDS)).isNull(); assertThat(params.getValue().getParameter(IntegrationJobParameters.ISSUE_KEYS)).isNull(); } + @Test + @DisplayName("launchPushAll with a selection passes the story ids as a comma-joined job parameter") + void launches_push_all_with_selection() throws Exception { + TenantContext.setCurrentTenant("org-1"); + TenantContext.setCurrentSchema("tenant_acme"); + UUID s1 = UUID.randomUUID(); + UUID s2 = UUID.randomUUID(); + + adapter().launchPushAll(JOB_ID, PROJECT, List.of(s1, s2)); + + ArgumentCaptor params = ArgumentCaptor.forClass(JobParameters.class); + verify(jobOperator).start(eq(jiraPushAllJob), params.capture()); + assertThat(params.getValue().getString(IntegrationJobParameters.STORY_IDS)) + .isEqualTo(s1 + "," + s2); + } + @Test @DisplayName("a failed launch fails the projection row so no client watches a phantom RUNNING job") void fails_projection_when_launch_fails() throws Exception { diff --git a/src/test/java/com/kntro/reqsai/gateway/infrastructure/batch/JiraPushAllReaderTest.java b/src/test/java/com/kntro/reqsai/gateway/infrastructure/batch/JiraPushAllReaderTest.java new file mode 100644 index 00000000..1766935e --- /dev/null +++ b/src/test/java/com/kntro/reqsai/gateway/infrastructure/batch/JiraPushAllReaderTest.java @@ -0,0 +1,99 @@ +package com.kntro.reqsai.gateway.infrastructure.batch; + +import com.kntro.reqsai.discovery.api.DiscoveryStoryReadPort; +import com.kntro.reqsai.discovery.api.StoryView; +import com.kntro.reqsai.gateway.application.notification.IntegrationJobProgressNotifier; +import com.kntro.reqsai.gateway.application.port.IntegrationSyncJobRepository; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJob; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJobType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.batch.infrastructure.item.support.ListItemReader; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; + +/** + * Unit tests for the push-all reader in {@link IntegrationBatchJobsConfiguration}: it filters the + * project backlog to the requested story ids (preserving order, ignoring ids not in the project) and + * plans the projection {@code total} to the filtered count. An absent/blank selection pushes all. + */ +@Tag("unit") +@ExtendWith(MockitoExtension.class) +@DisplayName("Batch: push-all reader filters the backlog to the selected story ids") +class JiraPushAllReaderTest { + + private static final UUID PROJECT = UUID.randomUUID(); + private static final UUID JOB_ID = UUID.randomUUID(); + + @Mock + private DiscoveryStoryReadPort stories; + @Mock + private IntegrationSyncJobRepository jobs; + @Mock + private IntegrationJobProgressNotifier progress; + + private final IntegrationBatchJobsConfiguration config = new IntegrationBatchJobsConfiguration(); + + private List readAll(ListItemReader reader) { + List out = new ArrayList<>(); + StoryView next; + while ((next = reader.read()) != null) { + out.add(next); + } + return out; + } + + private StoryView story(UUID id) { + return new StoryView(id, PROJECT, "Title", "user", "do", "benefit", "MEDIUM", null, List.of()); + } + + private void planningJobIsRunning() { + IntegrationSyncJob job = new IntegrationSyncJob(PROJECT, IntegrationSyncJobType.PUSH_ALL, 0, null); + lenient().when(jobs.findById(JOB_ID)).thenReturn(Optional.of(job)); + lenient().when(jobs.save(job)).thenReturn(job); + } + + @Test + @DisplayName("filters to the selected ids (order preserved), ignores unknown ids, and totals the filtered count") + void filters_to_selection() { + StoryView a = story(UUID.randomUUID()); + StoryView b = story(UUID.randomUUID()); + StoryView c = story(UUID.randomUUID()); + UUID missing = UUID.randomUUID(); + when(stories.listStories(PROJECT)).thenReturn(List.of(a, b, c)); + planningJobIsRunning(); + + // request c and a and a missing id; result must follow the backlog order (a, c) + String csv = c.storyId() + "," + a.storyId() + "," + missing; + ListItemReader reader = config.jiraPushAllReader( + JOB_ID.toString(), PROJECT.toString(), csv, stories, jobs, progress); + + List read = readAll(reader); + assertThat(read).extracting(StoryView::storyId).containsExactly(a.storyId(), c.storyId()); + } + + @Test + @DisplayName("an absent/blank selection pushes every story") + void no_selection_pushes_all() { + StoryView a = story(UUID.randomUUID()); + StoryView b = story(UUID.randomUUID()); + when(stories.listStories(PROJECT)).thenReturn(List.of(a, b)); + planningJobIsRunning(); + + ListItemReader reader = config.jiraPushAllReader( + JOB_ID.toString(), PROJECT.toString(), null, stories, jobs, progress); + + assertThat(readAll(reader)).extracting(StoryView::storyId).containsExactly(a.storyId(), b.storyId()); + } +} diff --git a/src/test/java/com/kntro/reqsai/gateway/interfaces/rest/JiraIntegrationPushIntegrationTest.java b/src/test/java/com/kntro/reqsai/gateway/interfaces/rest/JiraIntegrationPushIntegrationTest.java index 0b0a06a1..4c0e8fb8 100644 --- a/src/test/java/com/kntro/reqsai/gateway/interfaces/rest/JiraIntegrationPushIntegrationTest.java +++ b/src/test/java/com/kntro/reqsai/gateway/interfaces/rest/JiraIntegrationPushIntegrationTest.java @@ -116,10 +116,13 @@ void connects_targets_and_pushes() throws Exception { assertThat(push.hasNonNull("error")).isFalse(); // push-all is now an async job: 202 with a RUNNING snapshot, then poll to COMPLETED. + // An empty body {} selects all eligible stories (the unrestricted default). ResponseEntity pushAllRes = client().post() .uri("/api/projects/{p}/integration/jira/stories/push-all", projectId) .header("Authorization", TestJwtFactory.bearer(USER_ID, orgId, "ROLE_USER")) .header("Api-Version", "1") + .contentType(MediaType.APPLICATION_JSON) + .body(Map.of()) .exchange((req, res) -> ResponseEntity.status(res.getStatusCode()).body(res.bodyTo(String.class))); assertThat(pushAllRes.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); JsonNode accepted = JSON.readTree(pushAllRes.getBody()); @@ -142,6 +145,71 @@ void connects_targets_and_pushes() throws Exception { assertThat(batchInstances).isGreaterThanOrEqualTo(1); } + @Test + @DisplayName("push-all with a storyIds selection pushes only the selected stories") + void push_all_with_story_selection() throws Exception { + String suffix = UUID.randomUUID().toString().substring(0, 8); + String slug = "acme-" + suffix; + String schema = "tenant_" + slug; + String orgId = createOrg(suffix, slug); + UUID projectId = createProject(orgId, schema, "Selective Push"); + + ResponseEntity connectRes = connectJira(orgId); + assertThat(connectRes.getStatusCode()).isEqualTo(HttpStatus.CREATED); + String connectionId = JSON.readTree(connectRes.getBody()).get("id").asText(); + + // Seed three stories; only two will be selected for the push-all. + String story1 = createStoryReturningId(orgId, projectId, "First"); + createStoryReturningId(orgId, projectId, "Second"); + String story3 = createStoryReturningId(orgId, projectId, "Third"); + + setTarget(orgId, projectId, connectionId); + + // Push-all with a selection of two of the three stories (order preserved by the reader). + ResponseEntity pushAllRes = client().post() + .uri("/api/projects/{p}/integration/jira/stories/push-all", projectId) + .header("Authorization", TestJwtFactory.bearer(USER_ID, orgId, "ROLE_USER")) + .header("Api-Version", "1") + .contentType(MediaType.APPLICATION_JSON) + .body(Map.of("storyIds", java.util.List.of(story1, story3))) + .exchange((req, res) -> ResponseEntity.status(res.getStatusCode()).body(res.bodyTo(String.class))); + assertThat(pushAllRes.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); + JsonNode accepted = JSON.readTree(pushAllRes.getBody()); + assertThat(accepted.get("jobType").asText()).isEqualTo("PUSH_ALL"); + // total reflects the filtered selection, not the full backlog of three. + assertThat(accepted.get("total").asInt()).isEqualTo(2); + + JsonNode all = awaitJobCompletion(projectId, accepted.get("id").asText(), orgId); + assertThat(all.get("status").asText()).isEqualTo("COMPLETED"); + assertThat(all.get("total").asInt()).isEqualTo(2); + assertThat(all.get("processed").asInt()).isEqualTo(2); + assertThat(all.get("succeeded").asInt()).isEqualTo(2); + assertThat(all.get("failed").asInt()).isZero(); + } + + private String createStoryReturningId(String orgId, UUID projectId, String title) throws Exception { + ResponseEntity res = client().post().uri("/api/projects/{p}/stories", projectId) + .header("Authorization", TestJwtFactory.bearer(USER_ID, orgId, "ROLE_USER")) + .header("Api-Version", "1") + .contentType(MediaType.APPLICATION_JSON) + .body(Map.of("title", title, "role", "analyst", + "action", "do " + title, "benefit", "save time", "priority", "HIGH")) + .exchange((req, r) -> ResponseEntity.status(r.getStatusCode()).body(r.bodyTo(String.class))); + assertThat(res.getStatusCode()).isEqualTo(HttpStatus.CREATED); + return JSON.readTree(res.getBody()).get("id").asText(); + } + + private void setTarget(String orgId, UUID projectId, String connectionId) { + ResponseEntity res = client().put() + .uri("/api/projects/{p}/integration/jira/target", projectId) + .header("Authorization", TestJwtFactory.bearer(USER_ID, orgId, "ROLE_USER")) + .header("Api-Version", "1") + .contentType(MediaType.APPLICATION_JSON) + .body(Map.of("connectionId", connectionId, "jiraProjectKey", "PAY", "issueTypeName", "Story")) + .exchange((req, r) -> ResponseEntity.status(r.getStatusCode()).body(r.bodyTo(String.class))); + assertThat(res.getStatusCode()).isEqualTo(HttpStatus.OK); + } + /** Polls {@code GET .../jobs/{jobId}} until the job leaves RUNNING (max ~60s). */ private JsonNode awaitJobCompletion(UUID projectId, String jobId, String orgId) throws Exception { JsonNode job = null; From 0f20ea06d73647bbb9034ecab5b8dd255edb965b Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Fri, 10 Jul 2026 07:33:09 -0500 Subject: [PATCH 66/72] fix: block /api/auth/dev-token outside the dev profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DevTokenController mints a signed JWT for any user/org/role with no login (a bootstrap tool for exercising authenticated endpoints before the real login existed). It's @Profile("dev")-gated so its bean never registers elsewhere, but that annotation is invisible to Spring Security's filter chain — the /api/auth/** wildcard would have made the path fall through to permitAll regardless of profile if the bean ever did register outside dev (e.g. a profile misconfiguration in a shared environment). Adds an explicit, profile-aware rule evaluated before the wildcard: permitAll only when the dev profile is active, denyAll otherwise. --- .../security/SecurityConfiguration.java | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/kntro/reqsai/shared/infrastructure/security/SecurityConfiguration.java b/src/main/java/com/kntro/reqsai/shared/infrastructure/security/SecurityConfiguration.java index de0f8daf..72912f7a 100644 --- a/src/main/java/com/kntro/reqsai/shared/infrastructure/security/SecurityConfiguration.java +++ b/src/main/java/com/kntro/reqsai/shared/infrastructure/security/SecurityConfiguration.java @@ -7,6 +7,8 @@ import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; +import org.springframework.core.env.Profiles; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; import org.springframework.http.ProblemDetail; @@ -39,6 +41,9 @@ public class SecurityConfiguration { private static final String[] PUBLIC_ENDPOINTS = { + // NOTE: this wildcard also matches /api/auth/dev-token (DevTokenController, mints a JWT + // for any user/org/role, no login required). A dedicated, profile-aware rule for that one + // path is registered before this wildcard in securityFilterChain() — see the comment there. "/api/auth/**", "/api-docs/**", "/swagger-ui/**", @@ -69,22 +74,34 @@ public class SecurityConfiguration { private final TokenVerifier tokenVerifier; private final TenantSchemaResolver tenantSchemaResolver; private final CorsProperties corsProperties; + private final Environment environment; @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) { CorrelationFilter correlationFilter = new CorrelationFilter(); JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(tokenVerifier, tenantSchemaResolver); + boolean devProfileActive = environment.acceptsProfiles(Profiles.of("dev")); http .csrf(AbstractHttpConfigurer::disable) .cors(cors -> cors.configurationSource(corsConfigurationSource())) .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .authorizeHttpRequests(auth -> auth - .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() - .requestMatchers(HttpMethod.GET, PUBLIC_GET_ENDPOINTS).permitAll() - .requestMatchers(PUBLIC_ENDPOINTS).permitAll() - .anyRequest().authenticated()) + .authorizeHttpRequests(auth -> { + auth.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll(); + // Evaluated before the /api/auth/** wildcard below, so this specific rule wins. + // DevTokenController is @Profile("dev") (its bean never registers otherwise), but + // that annotation is invisible to Spring Security's filter chain — this rule is + // the actual enforcement if a profile misconfiguration ever activates it outside dev. + if (devProfileActive) { + auth.requestMatchers("/api/auth/dev-token").permitAll(); + } else { + auth.requestMatchers("/api/auth/dev-token").denyAll(); + } + auth.requestMatchers(HttpMethod.GET, PUBLIC_GET_ENDPOINTS).permitAll(); + auth.requestMatchers(PUBLIC_ENDPOINTS).permitAll(); + auth.anyRequest().authenticated(); + }) .exceptionHandling(ex -> ex.authenticationEntryPoint(unauthenticatedEntryPoint())) .addFilterBefore(correlationFilter, UsernamePasswordAuthenticationFilter.class) .addFilterAfter(jwtAuthenticationFilter, CorrelationFilter.class); From 844fda3ba42cc633fb83c4fffa5a85d0f290090a Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Fri, 10 Jul 2026 07:43:30 -0500 Subject: [PATCH 67/72] test(discovery): discriminate realtime frames by their wire type The STOMP realtime test filtered frames by message.type(), but every concrete SessionRealtimeMessage hardcodes type() as a fixed constant (it is a @JsonProperty getter, not a canonical record component). Force-casting any frame into the expected payload type therefore made type() lie: the automatic PRESENCE_STATE broadcast a client receives the instant it subscribes deserialized into e.g. SessionProcessingFailedMessage with type()==FAILED and a null reason, racing (and under CI load beating) the real message and failing the assertion. Read each frame as a raw Map, discriminate on the WIRE type discriminator, and convert only genuine matches (ignoring the type/presence-only fields that are not record components). Behaviour-neutral, test-only. --- .../RealtimeNotificationIntegrationTest.java | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/src/test/java/com/kntro/reqsai/discovery/application/notification/RealtimeNotificationIntegrationTest.java b/src/test/java/com/kntro/reqsai/discovery/application/notification/RealtimeNotificationIntegrationTest.java index dc2c5265..59ab21da 100644 --- a/src/test/java/com/kntro/reqsai/discovery/application/notification/RealtimeNotificationIntegrationTest.java +++ b/src/test/java/com/kntro/reqsai/discovery/application/notification/RealtimeNotificationIntegrationTest.java @@ -9,6 +9,8 @@ import com.kntro.reqsai.discovery.interfaces.notification.messages.SessionRealtimeMessage; import com.kntro.reqsai.discovery.interfaces.notification.messages.SessionStatusChangedMessage; import com.kntro.reqsai.discovery.interfaces.notification.messages.SessionStoryGeneratedMessage; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; import com.kntro.reqsai.testsupport.AbstractIntegrationTest; import com.kntro.reqsai.testsupport.TestJwtFactory; import org.junit.jupiter.api.AfterEach; @@ -64,6 +66,15 @@ class RealtimeNotificationIntegrationTest extends AbstractIntegrationTest { private static final String USER_ID = "00000000-0000-0000-0000-000000000001"; private static final String ORG_ID = "00000000-0000-0000-0000-0000000000aa"; + /** + * Converts a raw wire frame (Map) into the typed message once its wire {@code type} is confirmed. + * Ignores unknown properties: the wire carries a {@code type} discriminator (and presence frames + * carry {@code participants}/{@code count}) that are not canonical record components. + */ + private static final ObjectMapper WIRE_MAPPER = new ObjectMapper() + .findAndRegisterModules() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + @LocalServerPort private int port; @@ -175,18 +186,25 @@ private StompSession connect(String authorization) throws Exception { private BlockingQueue subscribe( StompSession session, UUID sessionId, Class payloadType, SessionEventType expectedType) { BlockingQueue queue = new LinkedBlockingQueue<>(); + // Read each frame as a raw Map first: every concrete message hardcodes type() as a fixed + // constant (not a JSON-mapped record component), so force-casting a frame into payloadType makes + // type() lie. In particular the automatic PRESENCE_STATE broadcast (sent the instant a client + // subscribes) would deserialize into e.g. SessionProcessingFailedMessage with type()==FAILED and + // a null reason, defeating a type()-based filter. Discriminate on the WIRE type instead, then + // convert only genuine matches into the typed record. session.subscribe("/topic/" + SessionTopics.of(sessionId), new StompFrameHandler() { @Override @NonNull public Type getPayloadType(@NonNull StompHeaders headers) { - return payloadType; + return java.util.Map.class; } @Override public void handleFrame(@NonNull StompHeaders headers, Object payload) { - T message = payloadType.cast(payload); - if (message.type() == expectedType) { - queue.add(message); + @SuppressWarnings("unchecked") + java.util.Map frame = (java.util.Map) payload; + if (expectedType.name().equals(String.valueOf(frame.get("type")))) { + queue.add(WIRE_MAPPER.convertValue(frame, payloadType)); } } }); From 0685deb0e21f6ee668800716472062095caf3f33 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Fri, 10 Jul 2026 07:57:53 -0500 Subject: [PATCH 68/72] fix: stop exposing metrics/modulith/info actuator endpoints over HTTP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit They required a JWT (never fully public), but any authenticated tenant user could reach them — more exposure than intended for internal architecture (modulith) and operational data (metrics). There's no platform-wide "ops" role in this codebase to gate them behind (authorization here is entirely tenant/org-scoped via @authz.*), and nothing consumes them today (no Prometheus/scraper, logs go to CloudWatch only; info returns nothing without management.info.* contributors configured). Not exposing them over HTTP at all is simpler and safer than inventing a role just for this. Only health stays exposed, still gated by show-details: when-authorized. --- src/main/resources/application.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 7f7f2eb6..b2628fcb 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -208,7 +208,14 @@ management: endpoints: web: exposure: - include: health,info,metrics,modulith + # Only health is exposed over HTTP. metrics/modulith reveal internal + # architecture and operational data with no legitimate consumer today + # (no Prometheus/scraper — logs go to CloudWatch only); info returns + # nothing useful without management.info.* contributors configured. + # There's no platform-wide "ops" role in this codebase to gate them + # behind (authorization here is entirely tenant/org-scoped) — not + # exposing them at all is simpler and safer than inventing one. + include: health endpoint: health: show-details: when-authorized From fba1965dbe2d3e67be4d38d436e20021c7a89943 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Fri, 10 Jul 2026 08:30:09 -0500 Subject: [PATCH 69/72] feat: wire Jira OAuth config into the ECS task definition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JIRA_OAUTH_CALLBACK_URL derived from the same frontend domain as FRONTEND_URL (not a separate literal, can't drift out of sync). INTEGRATIONS_ENCRYPTION_KEY, JIRA_OAUTH_CLIENT_ID, JIRA_OAUTH_CLIENT_SECRET, and JIRA_OAUTH_STATE_SECRET read from the new reqsai/production/jira secret (reqsai-infra), matching the existing jwt/smtp/ai secrets pattern — the execution role's read permission for it was already granted via Terraform. --- ecs/task-definition.json | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/ecs/task-definition.json b/ecs/task-definition.json index eb905f72..b41d4503 100644 --- a/ecs/task-definition.json +++ b/ecs/task-definition.json @@ -17,6 +17,10 @@ "name": "CORS_ALLOWED_ORIGINS", "value": "https://app.tamci.app" }, + { + "name": "JIRA_OAUTH_CALLBACK_URL", + "value": "https://app.tamci.app/settings/integrations/jira/callback" + }, { "name": "SPRINGDOC_API_DOCS_ENABLED", "value": "true" @@ -124,6 +128,22 @@ { "name": "OPENAI_API_KEY", "valueFrom": "arn:aws:secretsmanager:us-east-1:418272789689:secret:reqsai/production/ai-GTSPn8:openai_api_key::" + }, + { + "name": "INTEGRATIONS_ENCRYPTION_KEY", + "valueFrom": "arn:aws:secretsmanager:us-east-1:418272789689:secret:reqsai/production/jira-JsDzh5:encryption_key::" + }, + { + "name": "JIRA_OAUTH_CLIENT_ID", + "valueFrom": "arn:aws:secretsmanager:us-east-1:418272789689:secret:reqsai/production/jira-JsDzh5:oauth_client_id::" + }, + { + "name": "JIRA_OAUTH_CLIENT_SECRET", + "valueFrom": "arn:aws:secretsmanager:us-east-1:418272789689:secret:reqsai/production/jira-JsDzh5:oauth_client_secret::" + }, + { + "name": "JIRA_OAUTH_STATE_SECRET", + "valueFrom": "arn:aws:secretsmanager:us-east-1:418272789689:secret:reqsai/production/jira-JsDzh5:oauth_state_secret::" } ], "logConfiguration": { From 6b2245aa87f4e24a189af0cbd6fe55bd06bd513f Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Fri, 10 Jul 2026 08:44:36 -0500 Subject: [PATCH 70/72] feat: default Jira OAuth callback URL to FRONTEND_URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matches the pattern already used by Stripe's checkout success/cancel URLs (application.yml) — JIRA_OAUTH_CALLBACK_URL becomes an optional override instead of a required literal, so it can no longer drift out of sync with FRONTEND_URL. Drops the now-redundant explicit value from the deployed task definition. --- .env.example | 1 + ecs/task-definition.json | 4 ---- src/main/resources/application.yml | 2 +- 3 files changed, 2 insertions(+), 5 deletions(-) diff --git a/.env.example b/.env.example index ed909bbf..9eca50e0 100644 --- a/.env.example +++ b/.env.example @@ -149,6 +149,7 @@ JWT_ISSUER=reqsai # ── Integrations — Jira (gateway) ───────────────────────────────────────────── # INTEGRATIONS_ENCRYPTION_KEY=your-base64-32-byte-key-here # JIRA_OAUTH_CALLBACK_URL must match the app's Authorization → Callback URL exactly. +# Defaults to {FRONTEND_URL}/settings/integrations/jira/callback — only set this to override. # JIRA_OAUTH_CLIENT_ID=your-atlassian-oauth-client-id # JIRA_OAUTH_CLIENT_SECRET=your-atlassian-oauth-client-secret # JIRA_OAUTH_CALLBACK_URL=http://localhost:4200/settings/integrations/jira/callback diff --git a/ecs/task-definition.json b/ecs/task-definition.json index b41d4503..444e33f2 100644 --- a/ecs/task-definition.json +++ b/ecs/task-definition.json @@ -17,10 +17,6 @@ "name": "CORS_ALLOWED_ORIGINS", "value": "https://app.tamci.app" }, - { - "name": "JIRA_OAUTH_CALLBACK_URL", - "value": "https://app.tamci.app/settings/integrations/jira/callback" - }, { "name": "SPRINGDOC_API_DOCS_ENABLED", "value": "true" diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index a47e5af0..f5b51bda 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -169,7 +169,7 @@ reqsai: oauth: client-id: ${JIRA_OAUTH_CLIENT_ID:} client-secret: ${JIRA_OAUTH_CLIENT_SECRET:} - redirect-uri: ${JIRA_OAUTH_CALLBACK_URL:} + redirect-uri: ${JIRA_OAUTH_CALLBACK_URL:${FRONTEND_URL:http://localhost:4200}/settings/integrations/jira/callback} state-secret: ${JIRA_OAUTH_STATE_SECRET:} jwt: private-key-path: ${JWT_PRIVATE_KEY_PATH:classpath:certs/private_key.pem} From a05b01d71633e228709b3cf75cdf2169e7f9c05a Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Fri, 10 Jul 2026 09:13:00 -0500 Subject: [PATCH 71/72] feat: wire Stripe billing config into the ECS task definition BILLING_PAYMENT_PROVIDER=stripe flips off the fake in-memory gateway. WEB_APP_URL (same value as FRONTEND_URL) feeds the checkout success/cancel URL derivation already in application.yml, so no explicit STRIPE_SUCCESS_URL/CANCEL_URL is needed. STRIPE_API_KEY and STRIPE_WEBHOOK_SECRET read from the new reqsai/production/stripe secret (reqsai-infra), matching the existing jwt/smtp/ai/jira pattern. --- ecs/task-definition.json | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/ecs/task-definition.json b/ecs/task-definition.json index 444e33f2..a7a76e23 100644 --- a/ecs/task-definition.json +++ b/ecs/task-definition.json @@ -17,6 +17,26 @@ "name": "CORS_ALLOWED_ORIGINS", "value": "https://app.tamci.app" }, + { + "name": "WEB_APP_URL", + "value": "https://app.tamci.app" + }, + { + "name": "BILLING_PAYMENT_PROVIDER", + "value": "stripe" + }, + { + "name": "BILLING_CURRENCY", + "value": "USD" + }, + { + "name": "BILLING_PRO_STRIPE_PRICE_ID", + "value": "price_1TrSiGDRW48WB7COzoE13Ius" + }, + { + "name": "BILLING_ENTERPRISE_STRIPE_PRICE_ID", + "value": "price_1TrSikDRW48WB7COSEMwH3xf" + }, { "name": "SPRINGDOC_API_DOCS_ENABLED", "value": "true" @@ -140,6 +160,14 @@ { "name": "JIRA_OAUTH_STATE_SECRET", "valueFrom": "arn:aws:secretsmanager:us-east-1:418272789689:secret:reqsai/production/jira-JsDzh5:oauth_state_secret::" + }, + { + "name": "STRIPE_API_KEY", + "valueFrom": "arn:aws:secretsmanager:us-east-1:418272789689:secret:reqsai/production/stripe-GUmpLb:api_key::" + }, + { + "name": "STRIPE_WEBHOOK_SECRET", + "valueFrom": "arn:aws:secretsmanager:us-east-1:418272789689:secret:reqsai/production/stripe-GUmpLb:webhook_secret::" } ], "logConfiguration": { From 282dc8479c8f37d6b2642acff2f3d5917b12c07a Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Fri, 10 Jul 2026 10:02:27 -0500 Subject: [PATCH 72/72] docs: document Stripe billing and Jira callback default in CHANGELOG The Stripe/subscription billing feature (015aa9ec, 95127c13, 938e4503, 29b05fca, 8a40d8b2, 70a47b6d) was never recorded under [Unreleased]. Also notes JIRA_OAUTH_CALLBACK_URL's new FRONTEND_URL-derived default. --- CHANGELOG.md | 38 +++++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 16b0493f..f802ac83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,40 @@ follows [Semantic Versioning](https://semver.org/). _Bounded-context implementation (iam, billing, workspace, discovery, gateway) in progress._ +### Added (Billing / Stripe subscriptions — `feature/billing-subscription-payments-quota`) + +- **Paid subscription lifecycle** — the `Subscription` aggregate gains upgrade/cancel/reactivate/downgrade + transitions plus AI token-quota accounting with automatic period rollover, driven by new lifecycle + domain events. `PlanCatalog` defines PRO/ENTERPRISE limits; pricing is config-driven via + `BillingProperties` (`reqsai.billing.*`, display amounts in minor units + currency) — currently PRO + USD 49.00/mo and ENTERPRISE USD 149.00/mo, matching the marketing landing page. + New REST endpoints on the subscription controller: `PUT .../upgrade`, `PUT .../cancel`, + `PUT .../reactivate`, `GET .../usage`. +- **AI token metering** — `BillingModuleApi.recordTokenConsumption`/`hasTokenQuotaAvailable`/`planLimits` + let Discovery capture provider-reported token usage from each LLM response and meter it against the + tenant's subscription quota (best-effort — a metering failure never breaks generation). +- **Stripe as a real payment gateway** (`PaymentGatewayPort`, swapped in by + `reqsai.billing.payment-provider=stripe`; `fake` — synchronous plan activation, no charge — stays the + default) implemented over Spring `RestClient` + a hand-rolled HMAC verifier, no Stripe SDK, to keep the + dependency surface minimal: + - `StripePaymentGatewayAdapter` creates a hosted Checkout Session (subscription mode) and returns its + URL; the plan is only activated from the webhook, honoring the same `PlanChangeResult` contract as + the fake gateway so domain/handlers are unchanged. Checkout return URLs derive from `WEB_APP_URL` + (`.../billing/success`, `.../billing/cancel`) rather than the API host. + - `StripeWebhookParser` verifies the `Stripe-Signature` header (HMAC-SHA256 over + `.`) and maps events to a gateway-agnostic `PaymentWebhookEvent`. + - `ProcessPaymentWebhookCommandHandler` de-duplicates by event id (new `public.billing_processed_events`, + common migration `V13__billing_processed_events.sql`) and applies plan activation / downgrade / + past-due. New endpoint `POST /api/billing/webhooks/stripe` (signature-verified, JWT-exempt in + `SecurityConfiguration`). +- **Cross-module relay** — on upgrade/downgrade, Billing publishes a + `SubscriptionPlanChangedIntegrationEvent` on `billing::api`; Workspace mirrors the new plan limits onto + its `Organization` aggregate. +- **Docs** — [docs/BILLING.md](docs/BILLING.md) covers Stripe test-mode setup, product/price + configuration, local webhook forwarding via the Stripe CLI, and deployed-environment webhooks; + `.env.example` documents every billing/Stripe variable (provider flag, API key, per-plan Price ids, + webhook secret, return-URL overrides). + ### Added (Live session presence — `feature/discovery-presence`) - **Real-time presence for live discovery sessions** — the users currently viewing a live session are @@ -83,7 +117,9 @@ _Bounded-context implementation (iam, billing, workspace, discovery, gateway) in refresh token is persisted (`JIRA_AUTH_FAILED` on refresh failure). - **Config** (all optional; the app boots when unset): `reqsai.integrations.jira.oauth.client-id`, `client-secret`, `redirect-uri` (from `JIRA_OAUTH_CLIENT_ID` / `JIRA_OAUTH_CLIENT_SECRET` / - `JIRA_OAUTH_CALLBACK_URL`) and a dedicated `state-secret` (`JIRA_OAUTH_STATE_SECRET`; generate with + `JIRA_OAUTH_CALLBACK_URL`, defaulting to `${FRONTEND_URL}/settings/integrations/jira/callback` when + unset — matching the `WEB_APP_URL`-derived pattern used by Stripe's checkout return URLs) and a + dedicated `state-secret` (`JIRA_OAUTH_STATE_SECRET`; generate with `scripts/generate-oauth-state-secret.sh`). - Migration `V20260708055820__integration_connections_oauth.sql` (tenant, additive): adds `credential_type` (default `API_TOKEN`), `cloud_id`, `oauth_refresh_ciphertext`, `oauth_access_ciphertext`,