Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
7fe5787
Merge pull request #140 from Kntro-Soft/main
reqsai-release-bot[bot] Oct 12, 2026
2dfa325
fix(billing): check the AI allowance before upload, processing and co…
jhosepmyr Oct 13, 2026
abf58d8
feat(gateway): connect several Jira sites per organization
jhosepmyr Oct 13, 2026
bb54076
test(discovery): give the upload and processing handler tests the all…
jhosepmyr Oct 13, 2026
716bd80
feat(workspace): expose Jira project description and GitHub repositor…
jhosepmyr Oct 13, 2026
8d0cb90
Merge pull request #144 from Kntro-Soft/feature/143-jira-multi-site
jhosepmyr Oct 13, 2026
a8cfa50
chore: merge develop into feature/141-quota-gaps
jhosepmyr Oct 13, 2026
1314737
chore: merge develop into feature/145-source-profile
jhosepmyr Oct 13, 2026
f639bc5
Merge pull request #146 from Kntro-Soft/feature/145-source-profile
jhosepmyr Oct 13, 2026
83bebb3
chore: merge develop into feature/141-quota-gaps
jhosepmyr Oct 13, 2026
0d1bd52
fix(ci): list a branch's pull requests with GET in the branch cleanup
jhosepmyr Oct 13, 2026
4de6421
Merge pull request #142 from Kntro-Soft/feature/141-quota-gaps
jhosepmyr Oct 14, 2026
cbe9479
fix(docs): document the code, integrations, overview and search modul…
jhosepmyr Oct 14, 2026
d05a0fd
chore: merge develop into bugfix/147-branch-cleanup-pulls
jhosepmyr Oct 14, 2026
8edcf7a
Merge pull request #150 from Kntro-Soft/bugfix/149-openapi-groups
jhosepmyr Oct 14, 2026
f872691
Merge branch 'develop' into bugfix/147-branch-cleanup-pulls
jhosepmyr Oct 14, 2026
18785df
Merge pull request #148 from Kntro-Soft/bugfix/147-branch-cleanup-pulls
jhosepmyr Oct 14, 2026
ece3b60
chore(release): 1.5.0
jhosepmyr Oct 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions .github/workflows/branch-cleanup.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ plugins {
}

group = "com.kntro"
version = "1.4.0"
version = "1.5.0"
description = "backend-reqsai"

java {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,8 @@ public List<AppRepository> 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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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()));

Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -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()));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -32,7 +31,7 @@
* already-consumed code;</li>
* <li>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});</li>
* <li>on selection, enforce one active connection per org ({@code INTEGRATION_ALREADY_CONNECTED}),
* <li>on selection, refuse a site the org already has connected ({@code INTEGRATION_ALREADY_CONNECTED}),
* persist an encrypted OAUTH2 connection, and evict the cached tokens.</li>
* </ol>
*/
Expand Down Expand Up @@ -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());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ public interface IntegrationConnectionRepository {

List<IntegrationConnection> 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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) {}
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -97,7 +98,7 @@ public String verify(JiraApiContext ctx) {
/** GET /project/search → visible projects. */
public List<JiraProject> 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()
Expand Down Expand Up @@ -358,7 +359,7 @@ private <T> T exchange(java.util.function.Supplier<T> 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<JiraProject> values) {}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ public String verify(ProviderCredentials c) {
@Override
public List<RemoteProject> 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();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,9 @@ public List<IntegrationConnection> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,6 @@ public interface IntegrationConnectionJpaRepository extends JpaRepository<Integr

List<IntegrationConnection> findAllByOrganizationIdOrderByCreatedAtDesc(UUID organizationId);

boolean existsByOrganizationIdAndProviderAndStatusNot(
UUID organizationId, IntegrationProviderType provider, ConnectionStatus status);
boolean existsByOrganizationIdAndProviderAndSiteUrlAndStatusNot(
UUID organizationId, IntegrationProviderType provider, String siteUrl, ConnectionStatus status);
}
Original file line number Diff line number Diff line change
@@ -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) {}
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
10 changes: 10 additions & 0 deletions src/test/java/com/kntro/reqsai/SmokeTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,16 @@ void servesOwnOpenApiDocument() {
assertThat(res.getBody()).contains("Reqs-AI");
}

@Test
void documentsEveryModuleWithControllers() {
ResponseEntity<String> 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).
Expand Down
Loading
Loading