diff --git a/.github/workflows/branch-cleanup.yml b/.github/workflows/branch-cleanup.yml index 7aa22a9e..da715e1d 100644 --- a/.github/workflows/branch-cleanup.yml +++ b/.github/workflows/branch-cleanup.yml @@ -96,8 +96,15 @@ jobs: ;; esac - # Pull this branch's full PR history (open + closed + merged) in one call. - prs_json=$(gh api "repos/$REPO/pulls" -f "head=$OWNER:$branch" -f state=all --jq '.' 2>/dev/null || echo '[]') + # Pull this branch's full PR history (open + closed + merged) in one call. --method GET is + # required: gh api sends -f fields as a POST body unless told otherwise, which would hit the + # create-a-pull-request endpoint instead of listing. When the lookup fails the branch is left + # alone: without its PRs the open-PR and protect-label rules cannot be checked. + if ! prs_json=$(gh api --method GET "repos/$REPO/pulls" -f "head=$OWNER:$branch" -f state=all -f per_page=100 2>/dev/null); then + row "$branch" "Kept" "PR lookup failed" "left alone: its PRs could not be read" + kept=$((kept + 1)) + continue + fi has_open=$(echo "$prs_json" | jq -r 'any(.[]; .state == "open")') if [ "$has_open" = "true" ]; then diff --git a/CHANGELOG.md b/CHANGELOG.md index 39ffd4e2..22be8b06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,23 @@ follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +## [1.5.0] - 2026-10-13 + +### Fixed (Branch cleanup — `bugfix/147-branch-cleanup-pulls`, #147) +- The weekly branch cleanup read each branch's pull requests with `gh api -f`, which sends a POST to the create-pull-request endpoint instead of listing, so every lookup failed with 422 and the open-PR and `do-not-delete` protections were never checked. It now lists with `--method GET`, and a branch whose PRs cannot be read is left alone. + +### Fixed (OpenAPI groups — `bugfix/149-openapi-groups`, #149) +- Swagger UI now has groups for `codebase` (code copilot and GitHub), `gateway` (Jira integrations), `overview` and `search`; before, only workspace, discovery, iam and billing were documented, so those endpoints were missing from `/swagger-ui.html` and `/api-docs/*`. + +### Fixed (AI allowance gaps — `feature/141-quota-gaps`, #141) +- Uploading a recording (`POST /api/sessions/{id}/upload`), processing a session into stories (`POST /api/sessions/{id}/process`) and verifying a suggestion against the code (`POST /api/projects/{id}/suggestions/{id}/verify-code`) now check the monthly AI allowance first and answer `402 AI_QUOTA_EXCEEDED` when it is spent, before touching the session (it keeps its status). Recording live, transcribing, sharing, exporting and reading are not affected. + +### Added (Source profile data — `feature/145-source-profile`, #145) +- `GET .../integrations/{connectionId}/jira/projects` returns each project's `description` and `GET .../code/github/repositories` each repository's `language`, so the create-project wizard can fill the project's profile from the chosen sources. + +### Changed (Jira sites — `feature/143-jira-multi-site`, #143) +- An organization can connect several Jira sites, like GitHub accounts. The same site twice (by its normalized URL) is still refused with `409 INTEGRATION_ALREADY_CONNECTED`; disconnecting one site leaves the others. Tenant migration `V20261012133000` replaces the unique index per organization and provider with one per organization, provider and site. A project's single push target is unchanged. + ## [1.4.0] - 2026-10-12 ### Added (Share a session — `feature/137-session-share-link`, #137) diff --git a/build.gradle.kts b/build.gradle.kts index 16b69ab9..fbbd67a2 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -8,7 +8,7 @@ plugins { } group = "com.kntro" -version = "1.4.0" +version = "1.5.0" description = "backend-reqsai" java { diff --git a/src/main/java/com/kntro/reqsai/codebase/application/port/GitHubAppPort.java b/src/main/java/com/kntro/reqsai/codebase/application/port/GitHubAppPort.java index 86cf7285..4f8562a7 100644 --- a/src/main/java/com/kntro/reqsai/codebase/application/port/GitHubAppPort.java +++ b/src/main/java/com/kntro/reqsai/codebase/application/port/GitHubAppPort.java @@ -42,6 +42,7 @@ record Installation(long id, String accountLogin, String accountType, @Nullable } record AppRepository(String owner, String name, String defaultBranch, String htmlUrl, boolean isPrivate, - @Nullable String description, @Nullable Instant pushedAt) { + @Nullable String description, @Nullable Instant pushedAt, + @Nullable String language) { } } diff --git a/src/main/java/com/kntro/reqsai/codebase/infrastructure/github/GitHubAppAdapter.java b/src/main/java/com/kntro/reqsai/codebase/infrastructure/github/GitHubAppAdapter.java index f53787a5..bb61d87f 100644 --- a/src/main/java/com/kntro/reqsai/codebase/infrastructure/github/GitHubAppAdapter.java +++ b/src/main/java/com/kntro/reqsai/codebase/infrastructure/github/GitHubAppAdapter.java @@ -172,7 +172,8 @@ public List repositories(long installationId) { repo.path("html_url").asString(props.webUrl + "/" + owner + "/" + name), repo.path("private").asBoolean(false), text(repo.path("description")), - instant(repo.path("pushed_at")))); + instant(repo.path("pushed_at")), + text(repo.path("language")))); } if (items.size() < PAGE_SIZE) break; } diff --git a/src/main/java/com/kntro/reqsai/codebase/interfaces/rest/dto/response/GitHubRepositoryResponse.java b/src/main/java/com/kntro/reqsai/codebase/interfaces/rest/dto/response/GitHubRepositoryResponse.java index 0ca6f101..a64c2abb 100644 --- a/src/main/java/com/kntro/reqsai/codebase/interfaces/rest/dto/response/GitHubRepositoryResponse.java +++ b/src/main/java/com/kntro/reqsai/codebase/interfaces/rest/dto/response/GitHubRepositoryResponse.java @@ -17,6 +17,8 @@ public record GitHubRepositoryResponse( @JsonProperty("private") @Schema(name = "private") boolean isPrivate, @Schema(nullable = true) @Nullable String description, @Schema(nullable = true) @Nullable Instant pushedAt, + @Schema(description = "Main language GitHub reports for it", example = "Java", nullable = true) + @Nullable String language, @Schema(description = "Whether the project already reads it") boolean connected ) { } diff --git a/src/main/java/com/kntro/reqsai/codebase/interfaces/rest/mappers/GitHubResponseMapper.java b/src/main/java/com/kntro/reqsai/codebase/interfaces/rest/mappers/GitHubResponseMapper.java index 8c669e14..e0e992b5 100644 --- a/src/main/java/com/kntro/reqsai/codebase/interfaces/rest/mappers/GitHubResponseMapper.java +++ b/src/main/java/com/kntro/reqsai/codebase/interfaces/rest/mappers/GitHubResponseMapper.java @@ -36,6 +36,6 @@ public static GitHubRepositoryResponse toResponse(AvailableRepository available) var repo = available.repository(); return new GitHubRepositoryResponse(available.installationId(), repo.owner(), repo.name(), repo.owner() + "/" + repo.name(), repo.defaultBranch(), repo.htmlUrl(), repo.isPrivate(), - repo.description(), repo.pushedAt(), available.connected()); + repo.description(), repo.pushedAt(), repo.language(), available.connected()); } } diff --git a/src/main/java/com/kntro/reqsai/discovery/application/handler/StartDiscoveryProcessingCommandHandler.java b/src/main/java/com/kntro/reqsai/discovery/application/handler/StartDiscoveryProcessingCommandHandler.java index eb9d9abc..4c0360ad 100644 --- a/src/main/java/com/kntro/reqsai/discovery/application/handler/StartDiscoveryProcessingCommandHandler.java +++ b/src/main/java/com/kntro/reqsai/discovery/application/handler/StartDiscoveryProcessingCommandHandler.java @@ -1,5 +1,6 @@ package com.kntro.reqsai.discovery.application.handler; +import com.kntro.reqsai.shared.application.ai.AiQuotaGuard; import com.kntro.reqsai.shared.application.ai.AiFeature; import com.kntro.reqsai.shared.application.ai.AiUsageScope; import com.kntro.reqsai.discovery.application.command.StartDiscoveryProcessingCommand; @@ -58,9 +59,11 @@ public class StartDiscoveryProcessingCommandHandler { private final SessionSpeakerService sessionSpeakers; private final RealtimeSuggestionService contextService; private final SuggestionRepository suggestions; + private final AiQuotaGuard quota; @Transactional public ProcessingResult handle(StartDiscoveryProcessingCommand command) { + quota.assertAvailable(); DiscoverySession session = loadAndValidate(command.sessionId()); session.startProcessing(); sessions.save(session); diff --git a/src/main/java/com/kntro/reqsai/discovery/application/handler/UploadTranscriptCommandHandler.java b/src/main/java/com/kntro/reqsai/discovery/application/handler/UploadTranscriptCommandHandler.java index 6eeaa20c..4dcc61e2 100644 --- a/src/main/java/com/kntro/reqsai/discovery/application/handler/UploadTranscriptCommandHandler.java +++ b/src/main/java/com/kntro/reqsai/discovery/application/handler/UploadTranscriptCommandHandler.java @@ -1,5 +1,6 @@ package com.kntro.reqsai.discovery.application.handler; +import com.kntro.reqsai.shared.application.ai.AiQuotaGuard; import com.kntro.reqsai.discovery.application.command.UploadTranscriptCommand; import com.kntro.reqsai.discovery.application.port.DiscoverySessionRepository; import com.kntro.reqsai.discovery.application.port.TranscriptSegmentRepository; @@ -39,9 +40,11 @@ public class UploadTranscriptCommandHandler { private final TranscriptionPort transcription; private final TranscriptSegmentRepository segments; private final AiUsageReporter usage; + private final AiQuotaGuard quota; @Transactional public DiscoverySession handle(UploadTranscriptCommand command) { + quota.assertAvailable(); DiscoverySession session = sessions.findById(command.sessionId()) .orElseThrow(() -> DiscoveryExceptions.sessionNotFound(command.sessionId())); diff --git a/src/main/java/com/kntro/reqsai/discovery/application/handler/VerifySuggestionCodeCommandHandler.java b/src/main/java/com/kntro/reqsai/discovery/application/handler/VerifySuggestionCodeCommandHandler.java index be6e5c51..ae511282 100644 --- a/src/main/java/com/kntro/reqsai/discovery/application/handler/VerifySuggestionCodeCommandHandler.java +++ b/src/main/java/com/kntro/reqsai/discovery/application/handler/VerifySuggestionCodeCommandHandler.java @@ -1,5 +1,6 @@ package com.kntro.reqsai.discovery.application.handler; +import com.kntro.reqsai.shared.application.ai.AiQuotaGuard; import com.kntro.reqsai.discovery.application.port.SuggestionRepository; import com.kntro.reqsai.discovery.application.service.CodeVerificationService; import com.kntro.reqsai.discovery.application.service.SuggestionCodeVerificationJob; @@ -28,9 +29,11 @@ public class VerifySuggestionCodeCommandHandler { private final SuggestionRepository suggestions; private final CodeVerificationService verification; private final SuggestionCodeVerificationJob job; + private final AiQuotaGuard quota; @Transactional public Suggestion handle(UUID projectId, UUID suggestionId) { + quota.assertAvailable(); Suggestion suggestion = suggestions.findByIdAndProjectIdForUpdate(suggestionId, projectId) .orElseThrow(() -> DiscoveryExceptions.suggestionNotFound(suggestionId)); if (!verification.hasReadyRepository(projectId)) { 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 92352cdf..81048ac1 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 @@ -5,7 +5,6 @@ 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; @@ -17,7 +16,8 @@ /** * 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}. + * connection. An organization may connect several sites; the same site twice is rejected with + * {@code INTEGRATION_ALREADY_CONNECTED}. */ @Component @RequiredArgsConstructor @@ -28,12 +28,10 @@ public class ConnectJiraCommandHandler { @Transactional public IntegrationConnection handle(ConnectJiraCommand command) { - if (connections.existsByOrganizationIdAndProviderAndStatusNot( - command.organizationId(), IntegrationProviderType.JIRA, ConnectionStatus.DISCONNECTED)) { + String siteUrl = IntegrationConnection.normalizeSiteUrl(command.siteUrl()); + if (connections.existsActiveBySite(command.organizationId(), IntegrationProviderType.JIRA, siteUrl)) { 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(IntegrationProvider.ProviderCredentials.apiToken(siteUrl, command.email(), command.apiToken())); 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 de87ea7b..2f2f64ba 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 @@ -10,7 +10,6 @@ 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; import com.kntro.reqsai.gateway.domain.model.IntegrationConnection; import com.kntro.reqsai.gateway.domain.model.IntegrationProviderType; import com.kntro.reqsai.gateway.application.config.JiraOAuthProperties; @@ -32,7 +31,7 @@ * already-consumed code; *
  • 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});
  • - *
  • on selection, enforce one active connection per org ({@code INTEGRATION_ALREADY_CONNECTED}), + *
  • on selection, refuse a site the org already has connected ({@code INTEGRATION_ALREADY_CONNECTED}), * persist an encrypted OAUTH2 connection, and evict the cached tokens.
  • * */ @@ -78,8 +77,8 @@ public JiraOAuthCallbackResult handle(JiraOAuthCallbackCommand command) { return JiraOAuthCallbackResult.needsSiteSelection(sites); } - if (connections.existsByOrganizationIdAndProviderAndStatusNot( - command.organizationId(), IntegrationProviderType.JIRA, ConnectionStatus.DISCONNECTED)) { + if (connections.existsActiveBySite( + command.organizationId(), IntegrationProviderType.JIRA, IntegrationConnection.normalizeSiteUrl(chosen.url()))) { throw IntegrationsExceptions.alreadyConnected( command.organizationId(), IntegrationProviderType.JIRA.name()); } diff --git a/src/main/java/com/kntro/reqsai/gateway/application/port/IntegrationConnectionRepository.java b/src/main/java/com/kntro/reqsai/gateway/application/port/IntegrationConnectionRepository.java index 070ebcdd..ca0cd019 100644 --- a/src/main/java/com/kntro/reqsai/gateway/application/port/IntegrationConnectionRepository.java +++ b/src/main/java/com/kntro/reqsai/gateway/application/port/IntegrationConnectionRepository.java @@ -19,8 +19,8 @@ public interface IntegrationConnectionRepository { List findAllByOrganizationId(UUID organizationId); - boolean existsByOrganizationIdAndProviderAndStatusNot( - UUID organizationId, IntegrationProviderType provider, ConnectionStatus status); + /** Whether the organization already has a live (not disconnected) connection to this very site. */ + boolean existsActiveBySite(UUID organizationId, IntegrationProviderType provider, String siteUrl); void delete(IntegrationConnection connection); } 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 d2e71c20..d4a09f71 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 @@ -65,7 +65,7 @@ public static ProviderCredentials oauth(String siteUrl, String cloudId, String a } /** A remote project ({key,name}). */ - record RemoteProject(String key, String name) {} + record RemoteProject(String key, String name, @Nullable String description) {} /** A remote issue type ({id,name}). */ record RemoteIssueType(String id, String name) {} 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 70aaffda..6ac89a9d 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,5 +1,6 @@ package com.kntro.reqsai.gateway.infrastructure.jira; +import org.jspecify.annotations.Nullable; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -97,7 +98,7 @@ public String verify(JiraApiContext ctx) { /** GET /project/search → visible projects. */ public List listProjects(JiraApiContext ctx) { ProjectSearch search = exchange(() -> restClient.get() - .uri(ctx.apiBase() + "/project/search?maxResults=100") + .uri(ctx.apiBase() + "/project/search?expand=description&maxResults=100") .header("Authorization", ctx.authHeader()) .accept(MediaType.APPLICATION_JSON) .retrieve() @@ -358,7 +359,7 @@ private T exchange(java.util.function.Supplier call, String op) { private record Myself(String displayName) {} @JsonIgnoreProperties(ignoreUnknown = true) - public record JiraProject(String key, String name) {} + public record JiraProject(String key, String name, @Nullable String description) {} @JsonIgnoreProperties(ignoreUnknown = true) private record ProjectSearch(List values) {} 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 3f6d5d8b..a19161dd 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 @@ -39,7 +39,7 @@ public String verify(ProviderCredentials c) { @Override public List listProjects(ProviderCredentials c) { return jira.listProjects(contextFor(c)).stream() - .map(p -> new RemoteProject(p.key(), p.name())) + .map(p -> new RemoteProject(p.key(), p.name(), p.description())) .toList(); } diff --git a/src/main/java/com/kntro/reqsai/gateway/infrastructure/persistence/adapters/IntegrationConnectionRepositoryAdapter.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/persistence/adapters/IntegrationConnectionRepositoryAdapter.java index dd2c1a9b..4912fd27 100644 --- a/src/main/java/com/kntro/reqsai/gateway/infrastructure/persistence/adapters/IntegrationConnectionRepositoryAdapter.java +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/persistence/adapters/IntegrationConnectionRepositoryAdapter.java @@ -40,9 +40,9 @@ public List findAllByOrganizationId(UUID organizationId) } @Override - public boolean existsByOrganizationIdAndProviderAndStatusNot( - UUID organizationId, IntegrationProviderType provider, ConnectionStatus status) { - return jpa.existsByOrganizationIdAndProviderAndStatusNot(organizationId, provider, status); + public boolean existsActiveBySite(UUID organizationId, IntegrationProviderType provider, String siteUrl) { + return jpa.existsByOrganizationIdAndProviderAndSiteUrlAndStatusNot( + organizationId, provider, siteUrl, ConnectionStatus.DISCONNECTED); } @Override diff --git a/src/main/java/com/kntro/reqsai/gateway/infrastructure/persistence/repositories/IntegrationConnectionJpaRepository.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/persistence/repositories/IntegrationConnectionJpaRepository.java index adb18495..29db0ce2 100644 --- a/src/main/java/com/kntro/reqsai/gateway/infrastructure/persistence/repositories/IntegrationConnectionJpaRepository.java +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/persistence/repositories/IntegrationConnectionJpaRepository.java @@ -15,6 +15,6 @@ public interface IntegrationConnectionJpaRepository extends JpaRepository findAllByOrganizationIdOrderByCreatedAtDesc(UUID organizationId); - boolean existsByOrganizationIdAndProviderAndStatusNot( - UUID organizationId, IntegrationProviderType provider, ConnectionStatus status); + boolean existsByOrganizationIdAndProviderAndSiteUrlAndStatusNot( + UUID organizationId, IntegrationProviderType provider, String siteUrl, ConnectionStatus status); } diff --git a/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/JiraProjectResponse.java b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/JiraProjectResponse.java index 18190f4a..c91525a4 100644 --- a/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/JiraProjectResponse.java +++ b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/JiraProjectResponse.java @@ -1,7 +1,8 @@ package com.kntro.reqsai.gateway.interfaces.rest.dto.response; import io.swagger.v3.oas.annotations.media.Schema; +import org.jspecify.annotations.Nullable; /** A selectable Jira project. */ @Schema(description = "A Jira project visible to the connection") -public record JiraProjectResponse(String key, String name) {} +public record JiraProjectResponse(String key, String name, @Nullable String description) {} 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 94f2fa54..fd42ec9f 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 @@ -45,7 +45,7 @@ public static ConnectionTestResponse toResponse(ConnectionTestResult r) { } public static JiraProjectResponse toResponse(RemoteProject p) { - return new JiraProjectResponse(p.key(), p.name()); + return new JiraProjectResponse(p.key(), p.name(), p.description()); } public static JiraIssueTypeResponse toResponse(RemoteIssueType t) { diff --git a/src/main/java/com/kntro/reqsai/shared/infrastructure/documentation/openapi/OpenApiConfiguration.java b/src/main/java/com/kntro/reqsai/shared/infrastructure/documentation/openapi/OpenApiConfiguration.java index 9c4163fd..1d0c3a88 100644 --- a/src/main/java/com/kntro/reqsai/shared/infrastructure/documentation/openapi/OpenApiConfiguration.java +++ b/src/main/java/com/kntro/reqsai/shared/infrastructure/documentation/openapi/OpenApiConfiguration.java @@ -94,6 +94,38 @@ public GroupedOpenApi billingApi() { .build(); } + @Bean + public GroupedOpenApi codebaseApi() { + return GroupedOpenApi.builder() + .group("codebase") + .packagesToScan("com.kntro.reqsai.codebase.interfaces") + .build(); + } + + @Bean + public GroupedOpenApi gatewayApi() { + return GroupedOpenApi.builder() + .group("gateway") + .packagesToScan("com.kntro.reqsai.gateway.interfaces") + .build(); + } + + @Bean + public GroupedOpenApi overviewApi() { + return GroupedOpenApi.builder() + .group("overview") + .packagesToScan("com.kntro.reqsai.overview.interfaces") + .build(); + } + + @Bean + public GroupedOpenApi searchApi() { + return GroupedOpenApi.builder() + .group("search") + .packagesToScan("com.kntro.reqsai.search.interfaces") + .build(); + } + @Bean @Profile("dev") public GroupedOpenApi devToolsApi() { diff --git a/src/main/resources/db/migration/tenant/V20261012133000__integration_connections_per_site.sql b/src/main/resources/db/migration/tenant/V20261012133000__integration_connections_per_site.sql new file mode 100644 index 00000000..825dc081 --- /dev/null +++ b/src/main/resources/db/migration/tenant/V20261012133000__integration_connections_per_site.sql @@ -0,0 +1,5 @@ +DROP INDEX IF EXISTS uq_integration_connections_active_org_provider; + +CREATE UNIQUE INDEX uq_integration_connections_active_org_provider_site + ON integration_connections (organization_id, provider, site_url) + WHERE status <> 'DISCONNECTED'; diff --git a/src/test/java/com/kntro/reqsai/SmokeTest.java b/src/test/java/com/kntro/reqsai/SmokeTest.java index a072d74d..3b4bd102 100644 --- a/src/test/java/com/kntro/reqsai/SmokeTest.java +++ b/src/test/java/com/kntro/reqsai/SmokeTest.java @@ -72,6 +72,16 @@ void servesOwnOpenApiDocument() { assertThat(res.getBody()).contains("Reqs-AI"); } + @Test + void documentsEveryModuleWithControllers() { + ResponseEntity config = get("/api-docs/swagger-config"); + assertThat(config.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(config.getBody()).contains("workspace", "discovery", "iam", "billing", + "codebase", "gateway", "overview", "search"); + assertThat(get("/api-docs/codebase").getBody()).contains("/code"); + assertThat(get("/api-docs/gateway").getBody()).contains("/integrations"); + } + @Test void protectedActuatorEndpointRequiresAuth() { // metrics is exposed but must be secured (only health/** is public). diff --git a/src/test/java/com/kntro/reqsai/billing/AiUsageMeteringIntegrationTest.java b/src/test/java/com/kntro/reqsai/billing/AiUsageMeteringIntegrationTest.java index 0537b985..c222e9f1 100644 --- a/src/test/java/com/kntro/reqsai/billing/AiUsageMeteringIntegrationTest.java +++ b/src/test/java/com/kntro/reqsai/billing/AiUsageMeteringIntegrationTest.java @@ -200,6 +200,37 @@ void warnings_enforcement_and_rollover() { assertThat(problem.path("used").asLong()).isGreaterThanOrEqualTo(100_000L); assertThat(get(bearer, chat).getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(get(bearer, "/api/projects/" + org.projectId() + "/stories").getStatusCode()).isEqualTo(HttpStatus.OK); + + // Uploading a recording, processing a session into stories and verifying a suggestion against the code + // spend tokens too: they are refused, and the session keeps its state. + UUID session = UUID.randomUUID(); + jdbc.update("INSERT INTO \"tenant_" + org.slug() + "\".discovery_sessions (id, project_id, title, language, status," + + " created_at, updated_at) VALUES (?, ?, 'Reunión', 'es-PE', 'STOPPED', now(), now())", + session, org.projectId()); + ResponseEntity processed = client().post().uri("/api/sessions/" + session + "/process") + .header("Authorization", bearer).header("Api-Version", "1") + .exchange((req, res) -> ResponseEntity.status(res.getStatusCode()).body(res.bodyTo(String.class))); + assertThat(processed.getStatusCode()).isEqualTo(HttpStatus.PAYMENT_REQUIRED); + assertThat(JSON.readTree(processed.getBody()).path("code").asString()).isEqualTo("AI_QUOTA_EXCEEDED"); + assertThat(jdbc.queryForObject("SELECT status FROM \"tenant_" + org.slug() + "\".discovery_sessions WHERE id = ?", + String.class, session)).isEqualTo("STOPPED"); + var audio = new org.springframework.util.LinkedMultiValueMap(); + audio.add("file", new org.springframework.core.io.ByteArrayResource(new byte[] {1, 2, 3}) { + @Override + public String getFilename() { + return "reunion.wav"; + } + }); + ResponseEntity uploaded = client().post().uri("/api/sessions/" + session + "/upload") + .header("Authorization", bearer).header("Api-Version", "1") + .contentType(MediaType.MULTIPART_FORM_DATA).body(audio) + .exchange((req, res) -> ResponseEntity.status(res.getStatusCode()).body(res.bodyTo(String.class))); + assertThat(uploaded.getStatusCode()).isEqualTo(HttpStatus.PAYMENT_REQUIRED); + ResponseEntity verified = client().post() + .uri("/api/projects/" + org.projectId() + "/suggestions/" + UUID.randomUUID() + "/verify-code") + .header("Authorization", bearer).header("Api-Version", "1") + .exchange((req, res) -> ResponseEntity.status(res.getStatusCode()).body(res.bodyTo(String.class))); + assertThat(verified.getStatusCode()).isEqualTo(HttpStatus.PAYMENT_REQUIRED); clearInvocations(notifier); // The period elapses: the allowance is available again at once, and the first call starts a fresh one. diff --git a/src/test/java/com/kntro/reqsai/discovery/application/handler/StartDiscoveryProcessingCommandHandlerTest.java b/src/test/java/com/kntro/reqsai/discovery/application/handler/StartDiscoveryProcessingCommandHandlerTest.java index a92385de..8ab0f2a2 100644 --- a/src/test/java/com/kntro/reqsai/discovery/application/handler/StartDiscoveryProcessingCommandHandlerTest.java +++ b/src/test/java/com/kntro/reqsai/discovery/application/handler/StartDiscoveryProcessingCommandHandlerTest.java @@ -1,5 +1,6 @@ package com.kntro.reqsai.discovery.application.handler; +import com.kntro.reqsai.shared.application.ai.AiQuotaGuard; import com.kntro.reqsai.discovery.application.command.StartDiscoveryProcessingCommand; import com.kntro.reqsai.discovery.application.port.DiscoverySessionRepository; import com.kntro.reqsai.discovery.application.port.GenerationResult; @@ -63,6 +64,9 @@ class StartDiscoveryProcessingCommandHandlerTest { private RealtimeSuggestionService contextService; @Mock private SuggestionRepository suggestions; + @Mock + private AiQuotaGuard quota; + @InjectMocks private StartDiscoveryProcessingCommandHandler handler; @@ -192,6 +196,20 @@ void should_bound_the_context_query() { assertThat(query.getValue()).hasSizeLessThanOrEqualTo(6000); } + @Test + @DisplayName("should refuse before touching the session when the AI allowance is spent") + void should_refuse_when_quota_spent() { + UUID id = UUID.randomUUID(); + doThrow(new com.kntro.reqsai.shared.domain.exception.DomainException( + com.kntro.reqsai.discovery.domain.exception.DiscoveryError.SHARE_LINK_UNAVAILABLE, "spent")) + .when(quota).assertAvailable(); + + assertThatThrownBy(() -> handler.handle(new StartDiscoveryProcessingCommand(id))) + .isInstanceOf(com.kntro.reqsai.shared.domain.exception.DomainException.class); + + verifyNoInteractions(sessions); + } + @Test @DisplayName("should throw SESSION_NOT_FOUND when session does not exist") void should_throw_when_session_not_found() { diff --git a/src/test/java/com/kntro/reqsai/discovery/application/handler/UploadTranscriptCommandHandlerTest.java b/src/test/java/com/kntro/reqsai/discovery/application/handler/UploadTranscriptCommandHandlerTest.java index 87b90e6b..1dfa9a04 100644 --- a/src/test/java/com/kntro/reqsai/discovery/application/handler/UploadTranscriptCommandHandlerTest.java +++ b/src/test/java/com/kntro/reqsai/discovery/application/handler/UploadTranscriptCommandHandlerTest.java @@ -1,5 +1,6 @@ package com.kntro.reqsai.discovery.application.handler; +import com.kntro.reqsai.shared.application.ai.AiQuotaGuard; import com.kntro.reqsai.shared.application.ai.AiUsageReporter; import com.kntro.reqsai.discovery.application.command.UploadTranscriptCommand; import com.kntro.reqsai.discovery.application.port.DiscoverySessionRepository; @@ -43,6 +44,9 @@ class UploadTranscriptCommandHandlerTest { private TranscriptSegmentRepository segments; @Mock private AiUsageReporter usage; + @Mock + private AiQuotaGuard quota; + @InjectMocks private UploadTranscriptCommandHandler handler; diff --git a/src/test/java/com/kntro/reqsai/gateway/StubJiraOAuthConfig.java b/src/test/java/com/kntro/reqsai/gateway/StubJiraOAuthConfig.java index 65d50b14..616ab6ab 100644 --- a/src/test/java/com/kntro/reqsai/gateway/StubJiraOAuthConfig.java +++ b/src/test/java/com/kntro/reqsai/gateway/StubJiraOAuthConfig.java @@ -38,7 +38,7 @@ public String verify(JiraApiContext ctx) { @Override public List listProjects(JiraApiContext ctx) { apiBases.add(ctx.apiBase()); - return List.of(new JiraProject("PAY", "Payments")); + return List.of(new JiraProject("PAY", "Payments", "Pagos y cobros")); } @Override diff --git a/src/test/java/com/kntro/reqsai/gateway/StubJiraProviderConfig.java b/src/test/java/com/kntro/reqsai/gateway/StubJiraProviderConfig.java index c3fe8a40..18c68c7b 100644 --- a/src/test/java/com/kntro/reqsai/gateway/StubJiraProviderConfig.java +++ b/src/test/java/com/kntro/reqsai/gateway/StubJiraProviderConfig.java @@ -36,7 +36,7 @@ public String verify(ProviderCredentials credentials) { @Override public List listProjects(ProviderCredentials credentials) { - return List.of(new RemoteProject("PAY", "Payments")); + return List.of(new RemoteProject("PAY", "Payments", "Pagos y cobros")); } @Override diff --git a/src/test/java/com/kntro/reqsai/gateway/application/handler/ConnectJiraCommandHandlerTest.java b/src/test/java/com/kntro/reqsai/gateway/application/handler/ConnectJiraCommandHandlerTest.java index 4949763b..fc33359b 100644 --- a/src/test/java/com/kntro/reqsai/gateway/application/handler/ConnectJiraCommandHandlerTest.java +++ b/src/test/java/com/kntro/reqsai/gateway/application/handler/ConnectJiraCommandHandlerTest.java @@ -49,8 +49,8 @@ void setUp() { @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(connections.existsActiveBySite( + eq(orgId), eq(IntegrationProviderType.JIRA), eq("https://acme.atlassian.net"))).thenReturn(false); when(jiraProvider.verify(any())).thenReturn("Jane Admin"); when(connections.save(any(IntegrationConnection.class))).thenAnswer(i -> i.getArgument(0)); @@ -68,8 +68,8 @@ void connects_after_verifying() { @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); + when(connections.existsActiveBySite( + eq(orgId), eq(IntegrationProviderType.JIRA), eq("https://acme.atlassian.net"))).thenReturn(true); assertThatThrownBy(() -> handler.handle(new ConnectJiraCommand( orgId, "https://acme.atlassian.net", "pm@acme.com", "tok", UUID.randomUUID()))) @@ -83,8 +83,8 @@ void rejects_duplicate() { @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(connections.existsActiveBySite( + eq(orgId), eq(IntegrationProviderType.JIRA), eq("https://acme.atlassian.net"))).thenReturn(false); when(jiraProvider.verify(any())).thenThrow(IntegrationsInfrastructureExceptions.jiraAuthFailed()); assertThatThrownBy(() -> handler.handle(new ConnectJiraCommand( 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 index 362bc5d7..91ba6153 100644 --- a/src/test/java/com/kntro/reqsai/gateway/application/handler/JiraOAuthCallbackCommandHandlerTest.java +++ b/src/test/java/com/kntro/reqsai/gateway/application/handler/JiraOAuthCallbackCommandHandlerTest.java @@ -76,8 +76,7 @@ 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.existsActiveBySite(any(), any(), any())).thenReturn(false); when(connections.save(any(IntegrationConnection.class))).thenAnswer(i -> i.getArgument(0)); JiraOAuthCallbackResult result = handler.handle(command(null)); @@ -115,7 +114,7 @@ void two_step_multi_site_exchanges_code_once() { 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.existsActiveBySite(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). @@ -141,7 +140,7 @@ void chosen_cloud_id_persists() { 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.existsActiveBySite(any(), any(), any())).thenReturn(false); when(connections.save(any(IntegrationConnection.class))).thenAnswer(i -> i.getArgument(0)); JiraOAuthCallbackResult result = handler.handle(command("cloud-2")); @@ -157,8 +156,7 @@ 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); + when(connections.existsActiveBySite(any(), any(), any())).thenReturn(true); assertThatThrownBy(() -> handler.handle(command(null))) .isInstanceOf(DomainException.class); 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 4c0e8fb8..7b81c710 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 @@ -231,7 +231,24 @@ private JsonNode awaitJobCompletion(UUID projectId, String jobId, String orgId) } @Test - @DisplayName("rejects a second active Jira connection with 409") + @DisplayName("an organization connects several Jira sites, each one once") + void connects_several_sites() { + String suffix = UUID.randomUUID().toString().substring(0, 8); + String orgId = createOrg(suffix, "acme-" + suffix); + + assertThat(connectJira(orgId, "https://acme.atlassian.net").getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(connectJira(orgId, "https://beta.atlassian.net").getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(connectJira(orgId, "https://beta.atlassian.net/").getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + + ResponseEntity listed = client().get().uri("/api/organizations/{orgId}/integrations", 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(listed.getBody()).contains("https://acme.atlassian.net").contains("https://beta.atlassian.net"); + } + + @Test + @DisplayName("rejects the same Jira site twice with 409") void rejects_duplicate_connection() { String suffix = UUID.randomUUID().toString().substring(0, 8); String orgId = createOrg(suffix, "acme-" + suffix); @@ -262,11 +279,15 @@ void push_without_target() { } private ResponseEntity connectJira(String orgId) { + return connectJira(orgId, "https://acme.atlassian.net"); + } + + private ResponseEntity connectJira(String orgId, String siteUrl) { 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")) + .body(Map.of("siteUrl", siteUrl, "email", "pm@acme.com", "apiToken", "tok")) .exchange((req, res) -> ResponseEntity.status(res.getStatusCode()).body(res.bodyTo(String.class))); }