diff --git a/frontend b/frontend index cc7c44a3..4d314b5b 160000 --- a/frontend +++ b/frontend @@ -1 +1 @@ -Subproject commit cc7c44a3c0623f3e9259e5f388a27bdea0527100 +Subproject commit 4d314b5b70e0bea43feefe8597f9728db57098d1 diff --git a/java-ecosystem/libs/core/src/main/java/module-info.java b/java-ecosystem/libs/core/src/main/java/module-info.java index 9b44d3f4..8e792eee 100644 --- a/java-ecosystem/libs/core/src/main/java/module-info.java +++ b/java-ecosystem/libs/core/src/main/java/module-info.java @@ -166,4 +166,6 @@ to org.hibernate.orm.core, spring.beans, spring.context, spring.core; opens org.rostilos.codecrow.core.persistence.repository.qadoc to spring.core, spring.beans, spring.context; + + exports org.rostilos.codecrow.core.service.qadoc; } diff --git a/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/model/analysis/RagIndexStatus.java b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/model/analysis/RagIndexStatus.java index 0fa6c745..19656c33 100644 --- a/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/model/analysis/RagIndexStatus.java +++ b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/model/analysis/RagIndexStatus.java @@ -63,6 +63,10 @@ public class RagIndexStatus { @Column(name = "chunk_count") private Integer chunkCount; + /** Durable producer identity used to keep stale recovery from touching a newer run. */ + @Column(name = "active_job_id") + private Long activeJobId; + @PreUpdate protected void onUpdate() { updatedAt = OffsetDateTime.now(); @@ -198,4 +202,12 @@ public Integer getChunkCount() { public void setChunkCount(Integer chunkCount) { this.chunkCount = chunkCount; } + + public Long getActiveJobId() { + return activeJobId; + } + + public void setActiveJobId(Long activeJobId) { + this.activeJobId = activeJobId; + } } diff --git a/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/model/rag/RagIndexOperation.java b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/model/rag/RagIndexOperation.java index 120b59c1..be055a07 100644 --- a/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/model/rag/RagIndexOperation.java +++ b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/model/rag/RagIndexOperation.java @@ -43,6 +43,10 @@ public class RagIndexOperation { @Column(name = "job_id") private Long jobId; + /** Exact analysis-lock owner captured for safe abandoned-producer cleanup. */ + @Column(name = "analysis_lock_key", length = 500) + private String analysisLockKey; + @Column(name = "attempt_count", nullable = false) private int attemptCount; @@ -124,6 +128,8 @@ public void heartbeat() { public void setGeneration(RagBranchIndexGeneration generation) { this.generation = generation; } public Long getJobId() { return jobId; } public void setJobId(Long jobId) { this.jobId = jobId; } + public String getAnalysisLockKey() { return analysisLockKey; } + public void setAnalysisLockKey(String analysisLockKey) { this.analysisLockKey = analysisLockKey; } public int getAttemptCount() { return attemptCount; } public void setAttemptCount(int attemptCount) { this.attemptCount = attemptCount; } public OffsetDateTime getCreatedAt() { return createdAt; } diff --git a/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/persistence/repository/analysis/RagIndexStatusRepository.java b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/persistence/repository/analysis/RagIndexStatusRepository.java index 4b7da2d7..294f88da 100644 --- a/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/persistence/repository/analysis/RagIndexStatusRepository.java +++ b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/persistence/repository/analysis/RagIndexStatusRepository.java @@ -1,7 +1,9 @@ package org.rostilos.codecrow.core.persistence.repository.analysis; +import jakarta.persistence.LockModeType; import org.rostilos.codecrow.core.model.analysis.RagIndexStatus; import org.rostilos.codecrow.core.model.analysis.RagIndexingStatus; +import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -15,6 +17,10 @@ public interface RagIndexStatusRepository extends JpaRepository findByProjectId(Long projectId); + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT r FROM RagIndexStatus r WHERE r.project.id = :projectId") + Optional findByProjectIdForUpdate(@Param("projectId") Long projectId); + Optional findByWorkspaceNameAndProjectName(String workspaceName, String projectName); List findByStatus(RagIndexingStatus status); @@ -37,4 +43,3 @@ List findByWorkspaceAndStatus(@Param("workspace") String workspa void deleteByProjectId(Long projectId); } - diff --git a/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/persistence/repository/rag/RagBranchIndexRepository.java b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/persistence/repository/rag/RagBranchIndexRepository.java index 5660ee45..01c5e701 100644 --- a/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/persistence/repository/rag/RagBranchIndexRepository.java +++ b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/persistence/repository/rag/RagBranchIndexRepository.java @@ -19,6 +19,16 @@ @Repository public interface RagBranchIndexRepository extends JpaRepository { + interface OperatorAliasCandidate { + Long getProjectId(); + String getWorkspaceName(); + String getProjectNamespace(); + String getBranchName(); + String getRevision(); + String getCollectionName(); + RagBranchIndexKind getIndexKind(); + } + Optional findByProjectIdAndBranchName(Long projectId, String branchName); @Lock(LockModeType.PESSIMISTIC_WRITE) @@ -47,4 +57,26 @@ Optional findByProjectIdAndBranchNameForUpdate( @Query("SELECT b.branchName FROM RagBranchIndex b WHERE b.project.id = :projectId") List findBranchNamesByProjectId(@Param("projectId") Long projectId); + + /** + * Reads the immutable values needed by optional operator-alias repair. + * Returning a scalar projection lets the database transaction finish + * before the caller performs any potentially slow RAG/Qdrant request. + */ + @Query(""" + SELECT b.project.id AS projectId, + b.project.workspace.name AS workspaceName, + b.project.namespace AS projectNamespace, + b.branchName AS branchName, + b.activeGeneration.revision AS revision, + b.activeGeneration.collectionName AS collectionName, + b.indexKind AS indexKind + FROM RagBranchIndex b + WHERE b.activeGeneration IS NOT NULL + AND b.indexKind IN ( + org.rostilos.codecrow.core.model.rag.RagBranchIndexKind.PRIMARY, + org.rostilos.codecrow.core.model.rag.RagBranchIndexKind.DURABLE + ) + """) + List findOperatorAliasCandidates(); } diff --git a/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/persistence/repository/rag/RagIndexOperationRepository.java b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/persistence/repository/rag/RagIndexOperationRepository.java index 92d4b4c1..65f28ddc 100644 --- a/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/persistence/repository/rag/RagIndexOperationRepository.java +++ b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/persistence/repository/rag/RagIndexOperationRepository.java @@ -1,8 +1,12 @@ package org.rostilos.codecrow.core.persistence.repository.rag; +import jakarta.persistence.LockModeType; import org.rostilos.codecrow.core.model.rag.RagIndexOperation; import org.rostilos.codecrow.core.model.rag.RagIndexOperationStatus; +import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.time.OffsetDateTime; @@ -14,7 +18,50 @@ public interface RagIndexOperationRepository extends JpaRepository findByProjectIdAndOperationKey(Long projectId, String operationKey); + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT o FROM RagIndexOperation o WHERE o.id = :operationId") + Optional findByIdForUpdate(@Param("operationId") Long operationId); + List findByStatusInAndUpdatedAtBefore( List statuses, OffsetDateTime updatedBefore); + + boolean existsByProjectIdAndBranchNameAndStatusIn( + Long projectId, + String branchName, + List statuses); + + /** + * Finds already-failed operations whose durable projections still say the + * producer is active. This repairs drift created by older recovery code or + * by a partial recovery failure on the next scan. + */ + @Query(value = """ + SELECT o.* + FROM rag_index_operation o + WHERE o.status = 'FAILED' + AND ( + EXISTS ( + SELECT 1 FROM job j + WHERE j.id = o.job_id + AND j.status IN ('PENDING', 'QUEUED', 'RUNNING', 'WAITING') + ) + OR EXISTS ( + SELECT 1 FROM rag_index_status s + WHERE s.project_id = o.project_id + AND s.indexed_branch = o.branch_name + AND s.status IN ('INDEXING', 'UPDATING') + AND (s.active_job_id IS NULL OR s.active_job_id = o.job_id) + ) + OR EXISTS ( + SELECT 1 FROM analysis_lock l + WHERE l.project_id = o.project_id + AND l.branch_name = o.branch_name + AND l.analysis_type = 'RAG_INDEXING' + AND l.commit_hash IS NOT DISTINCT FROM o.to_revision + ) + ) + ORDER BY o.completed_at DESC, o.id DESC + """, nativeQuery = true) + List findFailedOperationsWithActiveProjections(); } diff --git a/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/service/QaDocDocumentService.java b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/service/QaDocDocumentService.java index 155a944f..f098ad96 100644 --- a/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/service/QaDocDocumentService.java +++ b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/service/QaDocDocumentService.java @@ -52,6 +52,14 @@ public Optional findLatestDocument(Long projectId, Long prNumber) return qaDocDocumentRepository.findByProjectIdAndPrNumber(projectId, prNumber); } + @Transactional(readOnly = true) + public Optional findDocumentById(Long documentId) { + if (documentId == null) { + return Optional.empty(); + } + return qaDocDocumentRepository.findById(documentId); + } + @Transactional(readOnly = true) public Optional findLatestDocumentForTask(Long projectId, String taskId, diff --git a/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/service/qadoc/QaDocContent.java b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/service/qadoc/QaDocContent.java new file mode 100644 index 00000000..940c549e --- /dev/null +++ b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/service/qadoc/QaDocContent.java @@ -0,0 +1,10 @@ +package org.rostilos.codecrow.core.service.qadoc; + +import java.util.List; + +public record QaDocContent( + String overviewMarkdown, + List testCases, + String environmentMarkdown +) { +} diff --git a/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/service/qadoc/QaDocContentParser.java b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/service/qadoc/QaDocContentParser.java new file mode 100644 index 00000000..e481c496 --- /dev/null +++ b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/service/qadoc/QaDocContentParser.java @@ -0,0 +1,390 @@ +package org.rostilos.codecrow.core.service.qadoc; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** Splits rendered QA markdown into overview, test cases, and environment/setup notes. */ +public final class QaDocContentParser { + + public static final String TEST_CASES_START = ""; + public static final String TEST_CASES_END = ""; + + private static final Pattern SCENARIO_PATTERN = Pattern.compile( + "(?m)^\\s*\\*\\*(.+?)\\*\\*\\s*\\((HIGH|MEDIUM|LOW)\\)[^\\r\\n]*$", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern HEADING_PATTERN = Pattern.compile("(?m)^\\s*(#{2,6})\\s+(.+?)\\s*$"); + private static final Pattern LEGACY_SECTION_PATTERN = Pattern.compile( + "(?im)^\\s*(#{2,4})\\s+(?:\\d+\\.\\s*)?Test Scenarios(?:\\s+by Area)?\\s*$" + ); + private static final Pattern NUMBERED_SECTION_HEADING = Pattern.compile("^\\d+\\.\\s+.+$"); + private static final Pattern KNOWN_LATER_SECTION_HEADING = Pattern.compile( + "^(?:Edge Cases(?: and Negative Testing)?|Negative Testing|Regression Risks|" + + "Environment(?: and Setup Notes)?|Setup Notes).*$", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern ENVIRONMENT_SECTION_HEADING = Pattern.compile( + "^(?:6\\.\\s+.+|(?:\\d+\\.\\s*)?(?:Environment and Setup Notes|" + + "Setup and Environment Notes|Environment Setup Notes|Environment Notes|Setup Notes))$", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern GENERATED_QA_FOOTER = Pattern.compile( + "(?ms)\\n\\s*---\\s*\\R\\s*\\*🐦 Generated by \\[CodeCrow]\\([^\\r\\n]+\\) " + + "QA Auto-Documentation\\*\\s*\\R\\s*" + + "\\s*$" + ); + + private QaDocContentParser() { + } + + public static QaDocContent parse(String markdown) { + String source = markdown == null + ? "" + : GENERATED_QA_FOOTER.matcher(markdown.trim()).replaceFirst("").trim(); + Section section = findTestCaseSection(source); + String withoutTestCases; + List testCases; + if (section == null) { + withoutTestCases = source; + testCases = parseTestCases(source); + } else { + withoutTestCases = (source.substring(0, section.start()) + + "\n\n" + + source.substring(section.end())) + .replace(TEST_CASES_START, "") + .replace(TEST_CASES_END, ""); + testCases = parseTestCases(section.content()); + } + + Section environmentSection = findEnvironmentSection(withoutTestCases); + String overview = environmentSection == null + ? normalizeDocumentPart(withoutTestCases) + : normalizeDocumentPart( + withoutTestCases.substring(0, environmentSection.start()) + + "\n\n" + + withoutTestCases.substring(environmentSection.end())); + String environment = environmentSection == null + ? null + : stripFirstHeading(environmentSection.content()); + + return new QaDocContent(overview, testCases, normalizeNullablePart(environment)); + } + + /** + * Parses only an explicitly marked section for unauthenticated disclosure. + * Legacy heading inference is intentionally excluded from this boundary. + */ + public static List parseMarkedTestCases(String markdown) { + String source = markdown == null ? "" : markdown.trim(); + MarkedSection section = findMarkedSection(source); + if (section == null) { + return List.of(); + } + return parseStructuredTestCases( + source.substring(section.testContentStart(), section.testContentEnd()).trim()); + } + + /** + * Keeps the compact Jira-facing parts of the rendered QA document while + * replacing the broad test-case and environment/setup bodies with their + * respective public-preview links. Original section headings and the + * generated ownership footer are retained. + */ + public static String replaceShareableSections( + String markdown, + String testCasesReplacement, + String environmentReplacement) { + String withTestCasesReplaced = replaceMarkedTestCaseBody(markdown, testCasesReplacement); + return replaceEnvironmentBody(withTestCasesReplaced, environmentReplacement); + } + + private static String replaceMarkedTestCaseBody(String markdown, String replacementMarkdown) { + String source = markdown == null ? "" : markdown.trim(); + String replacement = replacementMarkdown == null ? "" : replacementMarkdown.trim(); + if (replacement.isBlank()) { + throw new IllegalArgumentException("Test-case replacement is required."); + } + + MarkedSection section = findMarkedSection(source); + if (section == null) { + throw new IllegalArgumentException("A marked QA test-case section is required."); + } + + String laterSections = source + .substring(section.testContentEnd(), section.markerEnd()) + .trim(); + String afterMarker = source + .substring(section.markerEnd() + TEST_CASES_END.length()) + .trim(); + + StringBuilder result = new StringBuilder(source + .substring(0, section.contentStart()).stripTrailing()) + .append("\n") + .append(section.heading()) + .append("\n\n") + .append(replacement) + .append("\n") + .append(TEST_CASES_END); + if (!laterSections.isBlank()) { + result.append("\n\n").append(laterSections); + } + if (!afterMarker.isBlank()) { + result.append("\n\n").append(afterMarker); + } + return result.toString().trim(); + } + + private static String replaceEnvironmentBody(String markdown, String replacementMarkdown) { + String replacement = replacementMarkdown == null ? "" : replacementMarkdown.trim(); + if (replacement.isBlank()) { + throw new IllegalArgumentException("Environment replacement is required."); + } + + Section section = findEnvironmentSection(markdown); + if (section == null) { + return markdown; + } + + Matcher heading = HEADING_PATTERN.matcher(section.content()); + if (!heading.find()) { + return markdown; + } + + StringBuilder result = new StringBuilder(markdown + .substring(0, section.start()) + .stripTrailing()) + .append("\n\n") + .append(heading.group().trim()) + .append("\n\n") + .append(replacement); + String suffix = markdown.substring(section.end()).stripLeading(); + if (!suffix.isBlank()) { + result.append("\n\n").append(suffix); + } + return result.toString().trim(); + } + + private static Section findTestCaseSection(String markdown) { + MarkedSection marked = findMarkedSection(markdown); + if (marked != null) { + int sectionEnd = marked.testContentEnd() == marked.markerEnd() + ? marked.markerEnd() + TEST_CASES_END.length() + : marked.testContentEnd(); + return new Section( + marked.markerStart(), + sectionEnd, + markdown.substring(marked.testContentStart(), marked.testContentEnd()).trim()); + } + + Matcher legacy = LEGACY_SECTION_PATTERN.matcher(markdown); + if (!legacy.find()) { + return null; + } + int headingLevel = legacy.group(1).length(); + int sectionEnd = markdown.length(); + Matcher headings = HEADING_PATTERN.matcher(markdown); + headings.region(legacy.end(), markdown.length()); + while (headings.find()) { + if (headings.group(1).length() <= headingLevel) { + sectionEnd = headings.start(); + break; + } + } + return new Section(legacy.start(), sectionEnd, markdown.substring(legacy.start(), sectionEnd).trim()); + } + + /** + * Models occasionally put the closing marker after later peer sections. + * Treat the next heading at the test-section level (or higher) as the real + * boundary, while retaining the marker as the outer disclosure boundary. + */ + private static MarkedSection findMarkedSection(String markdown) { + int markerStart = markdown.indexOf(TEST_CASES_START); + if (markerStart < 0) { + return null; + } + int contentStart = markerStart + TEST_CASES_START.length(); + int markerEnd = markdown.indexOf(TEST_CASES_END, contentStart); + if (markerEnd < 0) { + return null; + } + + String markedContent = markdown.substring(contentStart, markerEnd); + Matcher testHeading = LEGACY_SECTION_PATTERN.matcher(markedContent); + if (!testHeading.find()) { + return new MarkedSection( + markerStart, + contentStart, + markerEnd, + contentStart, + markerEnd, + "### 3. Test Scenarios"); + } + + int testContentStart = contentStart + testHeading.start(); + int testHeadingEnd = contentStart + testHeading.end(); + int testContentEnd = markerEnd; + int headingLevel = testHeading.group(1).length(); + + Matcher followingHeadings = HEADING_PATTERN.matcher(markdown); + followingHeadings.region(testHeadingEnd, markerEnd); + while (followingHeadings.find()) { + int followingLevel = followingHeadings.group(1).length(); + String followingTitle = followingHeadings.group(2).trim(); + if (followingLevel < headingLevel + || (followingLevel == headingLevel + && isLaterDocumentSection(followingTitle))) { + testContentEnd = followingHeadings.start(); + break; + } + } + + return new MarkedSection( + markerStart, + contentStart, + markerEnd, + testContentStart, + testContentEnd, + testHeading.group().trim()); + } + + private static boolean isLaterDocumentSection(String headingTitle) { + return NUMBERED_SECTION_HEADING.matcher(headingTitle).matches() + || KNOWN_LATER_SECTION_HEADING.matcher(headingTitle).matches(); + } + + private static Section findEnvironmentSection(String markdown) { + Matcher headings = HEADING_PATTERN.matcher(markdown); + while (headings.find()) { + String title = headings.group(2).trim(); + if (!ENVIRONMENT_SECTION_HEADING.matcher(title).matches()) { + continue; + } + + int headingLevel = headings.group(1).length(); + int sectionStart = headings.start(); + int sectionEnd = findGeneratedFooterStart(markdown, headings.end()); + Matcher followingHeadings = HEADING_PATTERN.matcher(markdown); + followingHeadings.region(headings.end(), sectionEnd); + while (followingHeadings.find()) { + if (followingHeadings.group(1).length() <= headingLevel) { + sectionEnd = followingHeadings.start(); + break; + } + } + return new Section( + sectionStart, + sectionEnd, + markdown.substring(sectionStart, sectionEnd).trim()); + } + return null; + } + + private static int findGeneratedFooterStart(String markdown, int fromIndex) { + Matcher footer = GENERATED_QA_FOOTER.matcher(markdown); + return footer.find(fromIndex) ? footer.start() : markdown.length(); + } + + private static String stripFirstHeading(String section) { + return HEADING_PATTERN.matcher(section).replaceFirst("").trim(); + } + + private static String normalizeDocumentPart(String value) { + return value == null + ? "" + : value.trim().replaceAll("\\n{3,}", "\n\n"); + } + + private static String normalizeNullablePart(String value) { + String normalized = normalizeDocumentPart(value); + return normalized.isBlank() ? null : normalized; + } + + private static List parseTestCases(String section) { + List structured = parseStructuredTestCases(section); + if (!structured.isEmpty()) { + return structured; + } + + String fallback = stripSectionHeading(section); + if (fallback.isBlank()) { + return List.of(); + } + return List.of(new QaDocTestCase("Test scenarios", null, null, fallback)); + } + + private static List parseStructuredTestCases(String section) { + Matcher matcher = SCENARIO_PATTERN.matcher(section); + List matches = new ArrayList<>(); + while (matcher.find()) { + matches.add(new ScenarioMatch( + matcher.start(), + matcher.end(), + matcher.group(1).trim(), + matcher.group(2).toUpperCase(Locale.ROOT) + )); + } + + if (matches.isEmpty()) { + return List.of(); + } + + List testCases = new ArrayList<>(); + for (int index = 0; index < matches.size(); index++) { + ScenarioMatch current = matches.get(index); + int descriptionEnd = index + 1 < matches.size() + ? matches.get(index + 1).start() + : section.length(); + + Matcher interveningHeading = HEADING_PATTERN.matcher(section); + interveningHeading.region(current.end(), descriptionEnd); + if (interveningHeading.find()) { + descriptionEnd = interveningHeading.start(); + } + + String description = section.substring(current.end(), descriptionEnd).trim(); + testCases.add(new QaDocTestCase( + current.title(), + current.priority(), + findFunctionalArea(section, current.start()), + description + )); + } + return List.copyOf(testCases); + } + + private static String findFunctionalArea(String section, int beforePosition) { + Matcher headings = HEADING_PATTERN.matcher(section); + String latest = null; + while (headings.find() && headings.start() < beforePosition) { + String candidate = headings.group(2).trim(); + if (!candidate.toLowerCase(Locale.ROOT).contains("test scenarios")) { + latest = candidate; + } + } + return latest; + } + + private static String stripSectionHeading(String section) { + return LEGACY_SECTION_PATTERN.matcher(section).replaceFirst("").trim(); + } + + private record Section(int start, int end, String content) { + } + + private record MarkedSection( + int markerStart, + int contentStart, + int markerEnd, + int testContentStart, + int testContentEnd, + String heading + ) { + } + + private record ScenarioMatch(int start, int end, String title, String priority) { + } +} diff --git a/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/service/qadoc/QaDocPublicShareResource.java b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/service/qadoc/QaDocPublicShareResource.java new file mode 100644 index 00000000..e4a8e827 --- /dev/null +++ b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/service/qadoc/QaDocPublicShareResource.java @@ -0,0 +1,10 @@ +package org.rostilos.codecrow.core.service.qadoc; + +public final class QaDocPublicShareResource { + + /** Public, read-only preview of the intentionally shareable QA document tabs. */ + public static final String DOCUMENT = "qa-document"; + + private QaDocPublicShareResource() { + } +} diff --git a/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/service/qadoc/QaDocTestCase.java b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/service/qadoc/QaDocTestCase.java new file mode 100644 index 00000000..8bea1504 --- /dev/null +++ b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/service/qadoc/QaDocTestCase.java @@ -0,0 +1,10 @@ +package org.rostilos.codecrow.core.service.qadoc; + +public record QaDocTestCase( + String title, + String priority, + String functionalArea, + String descriptionMarkdown +) { +} + diff --git a/java-ecosystem/libs/core/src/main/resources/db/migration/managed/V2.27.0__rag_index_active_job.sql b/java-ecosystem/libs/core/src/main/resources/db/migration/managed/V2.27.0__rag_index_active_job.sql new file mode 100644 index 00000000..74714a2e --- /dev/null +++ b/java-ecosystem/libs/core/src/main/resources/db/migration/managed/V2.27.0__rag_index_active_job.sql @@ -0,0 +1,8 @@ +-- Correlate the project-level RAG status with the durable job that currently +-- owns it. Recovery uses this identity to preserve a newer producer even +-- before that producer has registered its exact-generation operation. +ALTER TABLE rag_index_status + ADD COLUMN IF NOT EXISTS active_job_id BIGINT; + +CREATE INDEX IF NOT EXISTS idx_rag_index_status_active_job + ON rag_index_status(active_job_id); diff --git a/java-ecosystem/libs/core/src/main/resources/db/migration/managed/V2.28.0__rag_operation_lock_owner.sql b/java-ecosystem/libs/core/src/main/resources/db/migration/managed/V2.28.0__rag_operation_lock_owner.sql new file mode 100644 index 00000000..ff423438 --- /dev/null +++ b/java-ecosystem/libs/core/src/main/resources/db/migration/managed/V2.28.0__rag_operation_lock_owner.sql @@ -0,0 +1,5 @@ +-- Persist the exact analysis-lock owner used by a RAG generation attempt. +-- Recovery releases only this key, so a same-revision retry cannot lose its +-- newly acquired lock to a delayed recovery pass. +ALTER TABLE rag_index_operation + ADD COLUMN IF NOT EXISTS analysis_lock_key VARCHAR(500); diff --git a/java-ecosystem/libs/core/src/test/java/org/rostilos/codecrow/core/service/qadoc/QaDocContentParserTest.java b/java-ecosystem/libs/core/src/test/java/org/rostilos/codecrow/core/service/qadoc/QaDocContentParserTest.java new file mode 100644 index 00000000..596e8bc9 --- /dev/null +++ b/java-ecosystem/libs/core/src/test/java/org/rostilos/codecrow/core/service/qadoc/QaDocContentParserTest.java @@ -0,0 +1,251 @@ +package org.rostilos.codecrow.core.service.qadoc; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class QaDocContentParserTest { + + @Test + void separatesMarkedTestCasesFromOverviewAndPreservesStructuredDetails() { + String markdown = """ + # QA Guide + + ## What Changed + A user-visible flow changed. + + + ## 3. Test Scenarios by Area + + ### Checkout + **Complete checkout** (HIGH) + - **Preconditions:** A product is in the cart + - **Steps:** + 1. Submit the order + - **Expected Result:** The confirmation is shown + + **Reject an empty address** (MEDIUM) + - **Expected Result:** A validation message is shown + + + ## Regression Risks + Verify saved carts. + """; + + QaDocContent result = QaDocContentParser.parse(markdown); + + assertThat(result.overviewMarkdown()) + .contains("What Changed", "Regression Risks") + .doesNotContain("Complete checkout", "codecrow-test-cases"); + assertThat(result.environmentMarkdown()).isNull(); + assertThat(result.testCases()).hasSize(2); + assertThat(result.testCases().get(0)) + .extracting(QaDocTestCase::title, QaDocTestCase::priority, QaDocTestCase::functionalArea) + .containsExactly("Complete checkout", "HIGH", "Checkout"); + assertThat(result.testCases().get(0).descriptionMarkdown()) + .contains("Preconditions", "Submit the order", "Expected Result"); + } + + @Test + void extractsLegacyEnglishTestScenarioSections() { + QaDocContent result = QaDocContentParser.parse(""" + ### 1. Change Summary + Summary + ### 3. Test Scenarios + **Open settings** (LOW) + - **Expected Result:** Settings appear + ### 4. Edge Cases + Try an empty state. + """); + + assertThat(result.testCases()).singleElement() + .extracting(QaDocTestCase::title) + .isEqualTo("Open settings"); + assertThat(result.overviewMarkdown()).doesNotContain("Open settings"); + } + + @Test + void publicParsingRequiresMarkersAndAStructuredScenario() { + assertThat(QaDocContentParser.parseMarkedTestCases(""" + + Secret overview text without a scenario + + """)).isEmpty(); + assertThat(QaDocContentParser.parseMarkedTestCases(""" + **Unmarked scenario** (HIGH) + - **Expected Result:** It works + """)).isEmpty(); + } + + @Test + void replacesOnlyTheMarkedTestCaseBodyAndKeepsTheNumberedSection() { + String result = QaDocContentParser.replaceShareableSections(""" + ### 1. Change Summary + Checkout now supports gift cards. + + ### 2. Scope + Web checkout only. + + + ### 3. Test Scenarios + + **Pay with a gift card** (HIGH) + - **Steps:** Enter a valid gift card. + - **Expected Result:** The balance is applied. + + + ### 4. Edge Cases + Test an expired gift card. + """, + "https://codecrow.cloud/share#token=ccs_public-token&tab=test-cases", + "https://codecrow.cloud/share#token=ccs_public-token&tab=environment"); + + assertThat(result) + .contains("### 1. Change Summary", "Checkout now supports gift cards") + .contains("### 2. Scope", "Web checkout only") + .contains("### 3. Test Scenarios\n\nhttps://codecrow.cloud/share#token=ccs_public-token") + .contains("### 4. Edge Cases", "Test an expired gift card") + .doesNotContain("Pay with a gift card", "The balance is applied"); + } + + @Test + void preservesLaterPeerSectionsWhenTheModelPlacesTheEndMarkerTooLate() { + String markdown = """ + ### 1. Change Summary + Checkout changed. + + + ### 3. Test Scenarios + ### Checkout + **Pay with a gift card** (HIGH) + - **Expected Result:** The balance is applied. + + ### 4. Edge Cases and Negative Testing + - Try an expired gift card. + + ### 5. Regression Risks + - Existing card payments. + + ### 6. Environment and Setup Notes + - Use the QA payment environment. + + """; + + QaDocContent parsed = QaDocContentParser.parse(markdown); + String comment = QaDocContentParser.replaceShareableSections( + markdown, + "https://codecrow.cloud/share#token=ccs_public-token&tab=test-cases", + "https://codecrow.cloud/share#token=ccs_public-token&tab=environment"); + + assertThat(parsed.testCases()).singleElement() + .extracting(QaDocTestCase::title, QaDocTestCase::functionalArea) + .containsExactly("Pay with a gift card", "Checkout"); + assertThat(parsed.overviewMarkdown()) + .contains( + "### 4. Edge Cases and Negative Testing", + "### 5. Regression Risks") + .doesNotContain( + "Pay with a gift card", + "codecrow-test-cases", + "### 6. Environment and Setup Notes", + "Use the QA payment environment"); + assertThat(parsed.environmentMarkdown()) + .isEqualTo("- Use the QA payment environment."); + assertThat(comment) + .contains("### 3. Test Scenarios\n\nhttps://codecrow.cloud/share#token=ccs_public-token&tab=test-cases") + .contains( + "### 4. Edge Cases and Negative Testing", + "Try an expired gift card", + "### 5. Regression Risks", + "Existing card payments", + "### 6. Environment and Setup Notes", + "https://codecrow.cloud/share#token=ccs_public-token&tab=environment") + .doesNotContain( + "Pay with a gift card", + "The balance is applied", + "Use the QA payment environment") + .contains("tab=test-cases\n\n\n### 4."); + } + + @Test + void replacesEnvironmentBodyAndPreservesTheGeneratedFooter() { + String markdown = """ + ### 1. Change Summary + Checkout changed. + + + ### 3. Test Scenarios + **Pay successfully** (HIGH) + - **Expected Result:** Payment succeeds. + + + ### 4. Edge Cases + Try an expired card. + + ### 5. Regression Risks + Verify saved cards. + + ### 6. Environment and Setup Notes + Use the QA payment environment. + + --- + *🐦 Generated by [CodeCrow](https://codecrow.app) QA Auto-Documentation* + + """; + QaDocContent parsed = QaDocContentParser.parse(markdown); + String comment = QaDocContentParser.replaceShareableSections( + markdown, + "https://codecrow.cloud/share#token=ccs_public-token&tab=test-cases", + "https://codecrow.cloud/share#token=ccs_public-token&tab=environment"); + + assertThat(parsed.overviewMarkdown()) + .contains("Change Summary", "Edge Cases", "Regression Risks") + .doesNotContain("Generated by", "codecrow-qa-autodoc"); + assertThat(parsed.environmentMarkdown()).isEqualTo("Use the QA payment environment."); + assertThat(comment) + .contains("### 4. Edge Cases", "Try an expired card") + .contains("### 5. Regression Risks", "Verify saved cards") + .contains("### 6. Environment and Setup Notes\n\n" + + "https://codecrow.cloud/share#token=ccs_public-token&tab=environment") + .contains("Generated by [CodeCrow]", "") + .doesNotContain("Use the QA payment environment."); + } + + @Test + void separatesSetupAndEnvironmentNotesFromAnUnmarkedDocument() { + QaDocContent parsed = QaDocContentParser.parse(""" + # QA Testing Guide — Checkout + + ## 1. What Changed + Saved cards can now be selected at checkout. + + ## 6. Setup and Environment Notes + - Enable the saved-card feature flag. + - Use a customer with an existing payment method. + """); + + assertThat(parsed.overviewMarkdown()) + .contains("What Changed", "Saved cards") + .doesNotContain("Setup and Environment Notes", "feature flag"); + assertThat(parsed.environmentMarkdown()) + .contains("Enable the saved-card feature flag", "existing payment method") + .doesNotContain("## 6."); + } + + @Test + void preservesSectionsAfterEnvironmentNotesInTheOverview() { + QaDocContent parsed = QaDocContentParser.parse(""" + ## Overview + Summary. + + ## Environment and Setup Notes + Use staging. + + ## Appendix + Contact the QA lead. + """); + + assertThat(parsed.environmentMarkdown()).isEqualTo("Use staging."); + assertThat(parsed.overviewMarkdown()).contains("Overview", "Appendix", "Contact the QA lead"); + } +} diff --git a/java-ecosystem/libs/public-share/pom.xml b/java-ecosystem/libs/public-share/pom.xml new file mode 100644 index 00000000..53c27784 --- /dev/null +++ b/java-ecosystem/libs/public-share/pom.xml @@ -0,0 +1,53 @@ + + + 4.0.0 + + + org.rostilos.codecrow + codecrow-parent + 1.0 + ../../pom.xml + + + codecrow-public-share + Purpose-neutral opaque public-share credentials and persistence + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-autoconfigure + + + + org.junit.jupiter + junit-jupiter + test + + + org.mockito + mockito-junit-jupiter + test + + + org.assertj + assertj-core + test + + + org.springframework.boot + spring-boot-starter-test + test + + + com.h2database + h2 + test + + + diff --git a/java-ecosystem/libs/public-share/src/main/java/module-info.java b/java-ecosystem/libs/public-share/src/main/java/module-info.java new file mode 100644 index 00000000..67019a59 --- /dev/null +++ b/java-ecosystem/libs/public-share/src/main/java/module-info.java @@ -0,0 +1,24 @@ +module org.rostilos.codecrow.publicshare { + requires jakarta.persistence; + requires spring.data.jpa; + requires spring.data.commons; + requires spring.context; + requires spring.boot.autoconfigure; + requires spring.beans; + requires spring.core; + requires spring.tx; + requires org.hibernate.orm.core; + + exports org.rostilos.codecrow.publicshare.api; + exports org.rostilos.codecrow.publicshare.config; + exports org.rostilos.codecrow.publicshare.service; + + opens org.rostilos.codecrow.publicshare.config + to spring.core, spring.beans, spring.context; + opens org.rostilos.codecrow.publicshare.model + to org.hibernate.orm.core, spring.core, spring.beans, spring.context; + opens org.rostilos.codecrow.publicshare.persistence + to spring.core, spring.beans, spring.context; + opens org.rostilos.codecrow.publicshare.service + to spring.core, spring.beans, spring.context; +} diff --git a/java-ecosystem/libs/public-share/src/main/java/org/rostilos/codecrow/publicshare/api/IssuedPublicShare.java b/java-ecosystem/libs/public-share/src/main/java/org/rostilos/codecrow/publicshare/api/IssuedPublicShare.java new file mode 100644 index 00000000..d7e40da3 --- /dev/null +++ b/java-ecosystem/libs/public-share/src/main/java/org/rostilos/codecrow/publicshare/api/IssuedPublicShare.java @@ -0,0 +1,14 @@ +package org.rostilos.codecrow.publicshare.api; + +/** A raw public-share credential. The value is returned once and is never persisted. */ +public record IssuedPublicShare(String token) { + + public String toFrontendUrl(String frontendBaseUrl) { + if (frontendBaseUrl == null || frontendBaseUrl.isBlank()) { + throw new IllegalArgumentException("Frontend base URL is required for a public share link."); + } + String base = frontendBaseUrl.trim().replaceAll("/+$", ""); + // Fragments are not sent in HTTP request targets or Referer headers. + return base + "/share#token=" + token; + } +} diff --git a/java-ecosystem/libs/public-share/src/main/java/org/rostilos/codecrow/publicshare/api/ResolvedPublicShare.java b/java-ecosystem/libs/public-share/src/main/java/org/rostilos/codecrow/publicshare/api/ResolvedPublicShare.java new file mode 100644 index 00000000..a3c7b7ef --- /dev/null +++ b/java-ecosystem/libs/public-share/src/main/java/org/rostilos/codecrow/publicshare/api/ResolvedPublicShare.java @@ -0,0 +1,4 @@ +package org.rostilos.codecrow.publicshare.api; + +public record ResolvedPublicShare(String resourceType, String resourceKey) { +} diff --git a/java-ecosystem/libs/public-share/src/main/java/org/rostilos/codecrow/publicshare/config/PublicShareAutoConfiguration.java b/java-ecosystem/libs/public-share/src/main/java/org/rostilos/codecrow/publicshare/config/PublicShareAutoConfiguration.java new file mode 100644 index 00000000..95c68f35 --- /dev/null +++ b/java-ecosystem/libs/public-share/src/main/java/org/rostilos/codecrow/publicshare/config/PublicShareAutoConfiguration.java @@ -0,0 +1,24 @@ +package org.rostilos.codecrow.publicshare.config; + +import org.rostilos.codecrow.publicshare.persistence.PublicShareLinkRepository; +import org.rostilos.codecrow.publicshare.service.PublicShareLinkService; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.context.annotation.Bean; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +/** + * Self-contained Spring Boot integration for opaque public-share credentials. + * Applications opt in by depending on this artifact and do not scan its + * entity, repository, or service implementation packages themselves. + */ +@AutoConfiguration +@EnableJpaRepositories(basePackages = "org.rostilos.codecrow.publicshare.persistence") +@EntityScan(basePackages = "org.rostilos.codecrow.publicshare.model") +public class PublicShareAutoConfiguration { + + @Bean + public PublicShareLinkService publicShareLinkService(PublicShareLinkRepository repository) { + return new PublicShareLinkService(repository); + } +} diff --git a/java-ecosystem/libs/public-share/src/main/java/org/rostilos/codecrow/publicshare/model/PublicShareLink.java b/java-ecosystem/libs/public-share/src/main/java/org/rostilos/codecrow/publicshare/model/PublicShareLink.java new file mode 100644 index 00000000..dfdb3028 --- /dev/null +++ b/java-ecosystem/libs/public-share/src/main/java/org/rostilos/codecrow/publicshare/model/PublicShareLink.java @@ -0,0 +1,89 @@ +package org.rostilos.codecrow.publicshare.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; + +import java.time.OffsetDateTime; + +@Entity +@Table( + name = "public_share_link", + uniqueConstraints = @UniqueConstraint( + name = "uq_public_share_link_token_hash", + columnNames = "token_hash" + ), + indexes = @Index( + name = "idx_public_share_link_resource", + columnList = "resource_type, resource_key" + ) +) +public class PublicShareLink { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(nullable = false, updatable = false) + private Long id; + + @Column(name = "resource_type", nullable = false, length = 80, updatable = false) + private String resourceType; + + @Column(name = "resource_key", nullable = false, length = 255, updatable = false) + private String resourceKey; + + @Column(name = "token_hash", nullable = false, length = 64, updatable = false) + private String tokenHash; + + @Column(name = "expires_at") + private OffsetDateTime expiresAt; + + @Column(name = "revoked_at") + private OffsetDateTime revokedAt; + + @Column(name = "created_at", nullable = false, updatable = false) + private OffsetDateTime createdAt; + + protected PublicShareLink() { + } + + public PublicShareLink(String resourceType, + String resourceKey, + String tokenHash, + OffsetDateTime expiresAt) { + this.resourceType = resourceType; + this.resourceKey = resourceKey; + this.tokenHash = tokenHash; + this.expiresAt = expiresAt; + } + + @PrePersist + void onCreate() { + if (createdAt == null) { + createdAt = OffsetDateTime.now(); + } + } + + public boolean isActiveAt(OffsetDateTime now) { + return revokedAt == null && (expiresAt == null || expiresAt.isAfter(now)); + } + + public void revoke(OffsetDateTime now) { + if (revokedAt == null) { + revokedAt = now; + } + } + + public Long getId() { return id; } + public String getResourceType() { return resourceType; } + public String getResourceKey() { return resourceKey; } + public String getTokenHash() { return tokenHash; } + public OffsetDateTime getExpiresAt() { return expiresAt; } + public OffsetDateTime getRevokedAt() { return revokedAt; } + public OffsetDateTime getCreatedAt() { return createdAt; } +} diff --git a/java-ecosystem/libs/public-share/src/main/java/org/rostilos/codecrow/publicshare/persistence/PublicShareLinkRepository.java b/java-ecosystem/libs/public-share/src/main/java/org/rostilos/codecrow/publicshare/persistence/PublicShareLinkRepository.java new file mode 100644 index 00000000..f7f6052a --- /dev/null +++ b/java-ecosystem/libs/public-share/src/main/java/org/rostilos/codecrow/publicshare/persistence/PublicShareLinkRepository.java @@ -0,0 +1,11 @@ +package org.rostilos.codecrow.publicshare.persistence; + +import org.rostilos.codecrow.publicshare.model.PublicShareLink; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface PublicShareLinkRepository extends JpaRepository { + + Optional findByTokenHash(String tokenHash); +} diff --git a/java-ecosystem/libs/public-share/src/main/java/org/rostilos/codecrow/publicshare/service/PublicShareLinkService.java b/java-ecosystem/libs/public-share/src/main/java/org/rostilos/codecrow/publicshare/service/PublicShareLinkService.java new file mode 100644 index 00000000..3cbe95e0 --- /dev/null +++ b/java-ecosystem/libs/public-share/src/main/java/org/rostilos/codecrow/publicshare/service/PublicShareLinkService.java @@ -0,0 +1,118 @@ +package org.rostilos.codecrow.publicshare.service; + +import org.rostilos.codecrow.publicshare.api.IssuedPublicShare; +import org.rostilos.codecrow.publicshare.api.ResolvedPublicShare; +import org.rostilos.codecrow.publicshare.model.PublicShareLink; +import org.rostilos.codecrow.publicshare.persistence.PublicShareLinkRepository; +import org.springframework.transaction.annotation.Transactional; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.time.OffsetDateTime; +import java.util.Base64; +import java.util.HexFormat; +import java.util.Optional; + +/** + * Issues and resolves opaque public-share credentials. + * + *

The token is deliberately unrelated to application JWTs: it contains no + * claims or identifiers, is generated from 256 bits of randomness, and only a + * one-way SHA-256 hash is stored.

+ */ +@Transactional +public class PublicShareLinkService { + + private static final String TOKEN_PREFIX = "ccs_"; + private static final int TOKEN_BYTES = 32; + + private final PublicShareLinkRepository repository; + private final SecureRandom secureRandom; + + public PublicShareLinkService(PublicShareLinkRepository repository) { + this(repository, new SecureRandom()); + } + + PublicShareLinkService(PublicShareLinkRepository repository, SecureRandom secureRandom) { + this.repository = repository; + this.secureRandom = secureRandom; + } + + public IssuedPublicShare issue(String resourceType, String resourceKey) { + return issue(resourceType, resourceKey, null); + } + + public IssuedPublicShare issue(String resourceType, + String resourceKey, + OffsetDateTime expiresAt) { + String normalizedType = requireValue(resourceType, "Resource type", 80); + String normalizedKey = requireValue(resourceKey, "Resource key", 255); + if (expiresAt != null && !expiresAt.isAfter(OffsetDateTime.now())) { + throw new IllegalArgumentException("Public share expiry must be in the future."); + } + + byte[] randomBytes = new byte[TOKEN_BYTES]; + secureRandom.nextBytes(randomBytes); + String token = TOKEN_PREFIX + Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes); + + repository.save(new PublicShareLink( + normalizedType, + normalizedKey, + hash(token), + expiresAt + )); + return new IssuedPublicShare(token); + } + + @Transactional(readOnly = true) + public Optional resolve(String token) { + if (!hasValidShape(token)) { + return Optional.empty(); + } + OffsetDateTime now = OffsetDateTime.now(); + return repository.findByTokenHash(hash(token)) + .filter(link -> link.isActiveAt(now)) + .map(link -> new ResolvedPublicShare( + link.getResourceType(), + link.getResourceKey() + )); + } + + public boolean revoke(String token) { + if (!hasValidShape(token)) { + return false; + } + Optional link = repository.findByTokenHash(hash(token)); + link.ifPresent(value -> value.revoke(OffsetDateTime.now())); + return link.isPresent(); + } + + private static boolean hasValidShape(String token) { + return token != null + && token.startsWith(TOKEN_PREFIX) + && token.length() >= TOKEN_PREFIX.length() + 40 + && token.length() <= 128; + } + + private static String requireValue(String value, String fieldName, int maxLength) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(fieldName + " is required."); + } + String normalized = value.trim(); + if (normalized.length() > maxLength) { + throw new IllegalArgumentException(fieldName + " is too long."); + } + return normalized; + } + + static String hash(String token) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(token.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 is unavailable", e); + } + } +} diff --git a/java-ecosystem/libs/public-share/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/java-ecosystem/libs/public-share/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000..0789fd57 --- /dev/null +++ b/java-ecosystem/libs/public-share/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +org.rostilos.codecrow.publicshare.config.PublicShareAutoConfiguration diff --git a/java-ecosystem/libs/public-share/src/main/resources/db/migration/managed/V2.25.0__public_share_links.sql b/java-ecosystem/libs/public-share/src/main/resources/db/migration/managed/V2.25.0__public_share_links.sql new file mode 100644 index 00000000..d6d22654 --- /dev/null +++ b/java-ecosystem/libs/public-share/src/main/resources/db/migration/managed/V2.25.0__public_share_links.sql @@ -0,0 +1,23 @@ +-- Generic opaque public-share credentials. +-- +-- Tokens are random bearer credentials. Only their SHA-256 hashes are stored; +-- the resource type/key pair is resolved server-side and is never encoded in +-- the public URL. + +CREATE TABLE IF NOT EXISTS public_share_link ( + id BIGSERIAL PRIMARY KEY, + resource_type VARCHAR(80) NOT NULL, + resource_key VARCHAR(255) NOT NULL, + token_hash VARCHAR(64) NOT NULL, + expires_at TIMESTAMPTZ, + revoked_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT uq_public_share_link_token_hash UNIQUE (token_hash) +); + +CREATE INDEX IF NOT EXISTS idx_public_share_link_resource + ON public_share_link(resource_type, resource_key); + +COMMENT ON TABLE public_share_link IS + 'Opaque, purpose-neutral public-share credentials. Raw bearer tokens are never persisted.'; diff --git a/java-ecosystem/libs/public-share/src/main/resources/db/migration/managed/V2.26.0__rename_qa_document_share_resource.sql b/java-ecosystem/libs/public-share/src/main/resources/db/migration/managed/V2.26.0__rename_qa_document_share_resource.sql new file mode 100644 index 00000000..be0cf571 --- /dev/null +++ b/java-ecosystem/libs/public-share/src/main/resources/db/migration/managed/V2.26.0__rename_qa_document_share_resource.sql @@ -0,0 +1,6 @@ +-- The QA share exposes the deliberately public Overview, Test cases, and +-- Environment tabs. Keep previously issued opaque credentials resolvable while +-- aligning the persisted resource type with that complete contract. +UPDATE public_share_link +SET resource_type = 'qa-document' +WHERE resource_type = 'qa-doc-test-cases'; diff --git a/java-ecosystem/libs/public-share/src/test/java/org/rostilos/codecrow/publicshare/config/PublicShareAutoConfigurationContextTest.java b/java-ecosystem/libs/public-share/src/test/java/org/rostilos/codecrow/publicshare/config/PublicShareAutoConfigurationContextTest.java new file mode 100644 index 00000000..980a155b --- /dev/null +++ b/java-ecosystem/libs/public-share/src/test/java/org/rostilos/codecrow/publicshare/config/PublicShareAutoConfigurationContextTest.java @@ -0,0 +1,47 @@ +package org.rostilos.codecrow.publicshare.config; + +import org.junit.jupiter.api.Test; +import org.rostilos.codecrow.publicshare.persistence.PublicShareLinkRepository; +import org.rostilos.codecrow.publicshare.service.PublicShareLinkService; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; +import org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration; +import org.springframework.boot.autoconfigure.transaction.TransactionAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +class PublicShareAutoConfigurationContextTest { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of( + DataSourceAutoConfiguration.class, + HibernateJpaAutoConfiguration.class, + TransactionAutoConfiguration.class, + PublicShareAutoConfiguration.class + )) + .withPropertyValues( + "spring.datasource.url=jdbc:h2:mem:public-share;MODE=PostgreSQL;DB_CLOSE_DELAY=-1", + "spring.datasource.username=sa", + "spring.datasource.password=", + "spring.jpa.hibernate.ddl-auto=create-drop" + ); + + @Test + void registersAndPersistsWithoutApplicationPackageScanning() { + contextRunner.run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasSingleBean(PublicShareLinkRepository.class); + assertThat(context).hasSingleBean(PublicShareLinkService.class); + + PublicShareLinkService service = context.getBean(PublicShareLinkService.class); + String token = service.issue("test-resource", "internal-42").token(); + + assertThat(service.resolve(token)) + .hasValueSatisfying(resolved -> { + assertThat(resolved.resourceType()).isEqualTo("test-resource"); + assertThat(resolved.resourceKey()).isEqualTo("internal-42"); + }); + }); + } +} diff --git a/java-ecosystem/libs/public-share/src/test/java/org/rostilos/codecrow/publicshare/config/PublicShareAutoConfigurationTest.java b/java-ecosystem/libs/public-share/src/test/java/org/rostilos/codecrow/publicshare/config/PublicShareAutoConfigurationTest.java new file mode 100644 index 00000000..b4b4ec07 --- /dev/null +++ b/java-ecosystem/libs/public-share/src/test/java/org/rostilos/codecrow/publicshare/config/PublicShareAutoConfigurationTest.java @@ -0,0 +1,27 @@ +package org.rostilos.codecrow.publicshare.config; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +import static org.assertj.core.api.Assertions.assertThat; + +class PublicShareAutoConfigurationTest { + + @Test + void ownsItsSpringIntegrationBoundaries() { + assertThat(PublicShareAutoConfiguration.class) + .hasAnnotation(AutoConfiguration.class); + + EnableJpaRepositories repositories = PublicShareAutoConfiguration.class + .getAnnotation(EnableJpaRepositories.class); + assertThat(repositories.basePackages()) + .containsExactly("org.rostilos.codecrow.publicshare.persistence"); + + EntityScan entities = PublicShareAutoConfiguration.class + .getAnnotation(EntityScan.class); + assertThat(entities.basePackages()) + .containsExactly("org.rostilos.codecrow.publicshare.model"); + } +} diff --git a/java-ecosystem/libs/public-share/src/test/java/org/rostilos/codecrow/publicshare/service/PublicShareLinkServiceTest.java b/java-ecosystem/libs/public-share/src/test/java/org/rostilos/codecrow/publicshare/service/PublicShareLinkServiceTest.java new file mode 100644 index 00000000..b90ad0f7 --- /dev/null +++ b/java-ecosystem/libs/public-share/src/test/java/org/rostilos/codecrow/publicshare/service/PublicShareLinkServiceTest.java @@ -0,0 +1,58 @@ +package org.rostilos.codecrow.publicshare.service; + +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.rostilos.codecrow.publicshare.api.IssuedPublicShare; +import org.rostilos.codecrow.publicshare.api.ResolvedPublicShare; +import org.rostilos.codecrow.publicshare.model.PublicShareLink; +import org.rostilos.codecrow.publicshare.persistence.PublicShareLinkRepository; + +import java.security.SecureRandom; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PublicShareLinkServiceTest { + + @Mock + private PublicShareLinkRepository repository; + @Mock + private SecureRandom secureRandom; + + @Test + void storesOnlyAHashAndResolvesTheOpaqueResourceReference() { + when(repository.save(any(PublicShareLink.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + PublicShareLinkService service = new PublicShareLinkService(repository, secureRandom); + + IssuedPublicShare issued = service.issue("report-preview", "internal-key-42"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(PublicShareLink.class); + verify(repository).save(captor.capture()); + PublicShareLink stored = captor.getValue(); + assertThat(issued.token()).startsWith("ccs_"); + assertThat(stored.getTokenHash()) + .hasSize(64) + .isNotEqualTo(issued.token()) + .isEqualTo(PublicShareLinkService.hash(issued.token())); + + when(repository.findByTokenHash(stored.getTokenHash())).thenReturn(Optional.of(stored)); + assertThat(service.resolve(issued.token())) + .contains(new ResolvedPublicShare("report-preview", "internal-key-42")); + } + + @Test + void rejectsValuesThatAreNotPublicShareTokensWithoutQueryingByRawValue() { + PublicShareLinkService service = new PublicShareLinkService(repository, secureRandom); + + assertThat(service.resolve("eyJhbGciOiJIUzI1NiJ9.jwt.payload")).isEmpty(); + assertThat(service.resolve("ccs_short")).isEmpty(); + } +} diff --git a/java-ecosystem/libs/rag-engine/src/main/java/org/rostilos/codecrow/ragengine/branch/BranchIndexGenerationBuildService.java b/java-ecosystem/libs/rag-engine/src/main/java/org/rostilos/codecrow/ragengine/branch/BranchIndexGenerationBuildService.java index fa465d3d..cbc3a45d 100644 --- a/java-ecosystem/libs/rag-engine/src/main/java/org/rostilos/codecrow/ragengine/branch/BranchIndexGenerationBuildService.java +++ b/java-ecosystem/libs/rag-engine/src/main/java/org/rostilos/codecrow/ragengine/branch/BranchIndexGenerationBuildService.java @@ -77,8 +77,26 @@ public Map build( List excludePatterns, Long jobId, Consumer> progressEvents) throws IOException { + return build(project, connection, vcsWorkspace, repoSlug, branch, revision, + kind, includePatterns, excludePatterns, jobId, null, progressEvents); + } + + public Map build( + Project project, + VcsConnection connection, + String vcsWorkspace, + String repoSlug, + String branch, + String revision, + RagBranchIndexKind kind, + List includePatterns, + List excludePatterns, + Long jobId, + String analysisLockKey, + Consumer> progressEvents) throws IOException { return buildInternal(project, connection, vcsWorkspace, repoSlug, branch, - revision, kind, includePatterns, excludePatterns, jobId, progressEvents, false); + revision, kind, includePatterns, excludePatterns, jobId, + analysisLockKey, progressEvents, false); } /** @@ -98,8 +116,26 @@ public Map rebuild( List excludePatterns, Long jobId, Consumer> progressEvents) throws IOException { + return rebuild(project, connection, vcsWorkspace, repoSlug, branch, revision, + kind, includePatterns, excludePatterns, jobId, null, progressEvents); + } + + public Map rebuild( + Project project, + VcsConnection connection, + String vcsWorkspace, + String repoSlug, + String branch, + String revision, + RagBranchIndexKind kind, + List includePatterns, + List excludePatterns, + Long jobId, + String analysisLockKey, + Consumer> progressEvents) throws IOException { return buildInternal(project, connection, vcsWorkspace, repoSlug, branch, - revision, kind, includePatterns, excludePatterns, jobId, progressEvents, true); + revision, kind, includePatterns, excludePatterns, jobId, + analysisLockKey, progressEvents, true); } public Map build( @@ -114,7 +150,7 @@ public Map build( List excludePatterns, Long jobId) throws IOException { return buildInternal(project, connection, vcsWorkspace, repoSlug, branch, - revision, kind, includePatterns, excludePatterns, jobId, null, false); + revision, kind, includePatterns, excludePatterns, jobId, null, null, false); } private Map buildInternal( @@ -128,6 +164,7 @@ private Map buildInternal( List includePatterns, List excludePatterns, Long jobId, + String analysisLockKey, Consumer> progressEvents, boolean forceRebuild) throws IOException { var registration = registryService.registerBuild( @@ -142,7 +179,8 @@ private Map buildInternal( "generation_manifest_sha256", registration.generation().getManifestDigest()); } - registryService.startBuild(registration.operation().getId(), jobId); + registryService.startBuild( + registration.operation().getId(), jobId, analysisLockKey); ScheduledFuture heartbeat = heartbeatExecutor.scheduleAtFixedRate( () -> heartbeat(registration.operation().getId()), HEARTBEAT_INTERVAL_SECONDS, diff --git a/java-ecosystem/libs/rag-engine/src/main/java/org/rostilos/codecrow/ragengine/branch/BranchIndexMaintenanceService.java b/java-ecosystem/libs/rag-engine/src/main/java/org/rostilos/codecrow/ragengine/branch/BranchIndexMaintenanceService.java index d5813aed..2b5accdd 100644 --- a/java-ecosystem/libs/rag-engine/src/main/java/org/rostilos/codecrow/ragengine/branch/BranchIndexMaintenanceService.java +++ b/java-ecosystem/libs/rag-engine/src/main/java/org/rostilos/codecrow/ragengine/branch/BranchIndexMaintenanceService.java @@ -188,7 +188,7 @@ private void rebuildOne(Project project, BranchBuildPlan plan, Consumer result = generationBuildService.rebuild( @@ -201,7 +201,7 @@ private void rebuildOne(Project project, BranchBuildPlan plan, Consumer { + job.getId(), lock.get(), event -> { Map forwarded = new LinkedHashMap<>(event); forwarded.put("type", "progress"); forwarded.put("branch", branch); @@ -223,7 +223,8 @@ private void rebuildOne(Project project, BranchBuildPlan plan, Consumer findAvailableGeneration( @Transactional public void heartbeatBuild(long operationId) { - RagIndexOperation operation = requireOperation(operationId); + RagIndexOperation operation = requireOperationForUpdate(operationId); operation.heartbeat(); operationRepository.save(operation); } @@ -226,6 +255,17 @@ public List findRecoverableOperations(OffsetDateTime updatedB updatedBefore); } + public List findFailedOperationsWithActiveProjections() { + return operationRepository.findFailedOperationsWithActiveProjections(); + } + + public boolean hasLiveOperation(long projectId, String branchName) { + return operationRepository.existsByProjectIdAndBranchNameAndStatusIn( + projectId, + requireText(branchName, "branchName"), + List.of(RagIndexOperationStatus.PENDING, RagIndexOperationStatus.RUNNING)); + } + static String physicalCollectionName( Project project, String branchName, @@ -254,8 +294,8 @@ static String operationKey( + nullToEmpty(representationFingerprint)); } - private RagIndexOperation requireOperation(long operationId) { - return operationRepository.findById(operationId) + private RagIndexOperation requireOperationForUpdate(long operationId) { + return operationRepository.findByIdForUpdate(operationId) .orElseThrow(() -> new IllegalArgumentException("RAG index operation not found: " + operationId)); } @@ -272,6 +312,10 @@ private static String requireText(String value, String field) { return value.trim(); } + private static String normalizeOptional(String value) { + return value == null || value.isBlank() ? null : value.trim(); + } + private static String nullToEmpty(String value) { return value == null ? "" : value; } diff --git a/java-ecosystem/libs/rag-engine/src/main/java/org/rostilos/codecrow/ragengine/service/RagIndexTrackingService.java b/java-ecosystem/libs/rag-engine/src/main/java/org/rostilos/codecrow/ragengine/service/RagIndexTrackingService.java index 6e27a9d6..1cc28523 100644 --- a/java-ecosystem/libs/rag-engine/src/main/java/org/rostilos/codecrow/ragengine/service/RagIndexTrackingService.java +++ b/java-ecosystem/libs/rag-engine/src/main/java/org/rostilos/codecrow/ragengine/service/RagIndexTrackingService.java @@ -10,6 +10,7 @@ import org.springframework.transaction.annotation.Transactional; import java.time.OffsetDateTime; +import java.util.Objects; import java.util.Optional; @Service @@ -34,8 +35,13 @@ public Optional getIndexStatus(Project project) { } @Transactional - public RagIndexStatus markIndexingStarted(Project project, String branchName, String commitHash) { - Optional existingOpt = ragIndexStatusRepository.findByProjectId(project.getId()); + public RagIndexStatus markIndexingStarted( + Project project, + String branchName, + String commitHash, + Long activeJobId) { + Optional existingOpt = + ragIndexStatusRepository.findByProjectIdForUpdate(project.getId()); RagIndexStatus status; if (existingOpt.isPresent()) { @@ -44,6 +50,7 @@ public RagIndexStatus markIndexingStarted(Project project, String branchName, St status.setIndexedBranch(branchName); status.setIndexedCommitHash(commitHash); status.setErrorMessage(null); + status.setActiveJobId(activeJobId); } else { status = new RagIndexStatus(); status.setProject(project); @@ -53,6 +60,7 @@ public RagIndexStatus markIndexingStarted(Project project, String branchName, St status.setIndexedBranch(branchName); status.setIndexedCommitHash(commitHash); status.setCollectionName(generateCollectionName(project)); + status.setActiveJobId(activeJobId); } status = ragIndexStatusRepository.save(status); @@ -63,10 +71,26 @@ public RagIndexStatus markIndexingStarted(Project project, String branchName, St @Transactional public RagIndexStatus markIndexingCompleted(Project project, String branchName, String commitHash, Integer filesIndexed, Integer chunkCount) { - RagIndexStatus status = ragIndexStatusRepository.findByProjectId(project.getId()) + return markIndexingCompleted( + project, branchName, commitHash, filesIndexed, chunkCount, null); + } + + @Transactional + public RagIndexStatus markIndexingCompleted( + Project project, + String branchName, + String commitHash, + Integer filesIndexed, + Integer chunkCount, + Long expectedActiveJobId) { + RagIndexStatus status = ragIndexStatusRepository.findByProjectIdForUpdate(project.getId()) .orElseThrow( () -> new IllegalStateException("RAG index status not found for project: " + project.getId())); + if (!ownsStatus(status, expectedActiveJobId, "complete full indexing")) { + return status; + } + status.setStatus(RagIndexingStatus.INDEXED); status.setIndexedBranch(branchName); status.setIndexedCommitHash(commitHash); @@ -76,6 +100,7 @@ public RagIndexStatus markIndexingCompleted(Project project, String branchName, } status.setLastIndexedAt(OffsetDateTime.now()); status.setErrorMessage(null); + status.setActiveJobId(null); // Reset failed incremental count on successful full index status.resetFailedIncrementalCount(); @@ -87,13 +112,26 @@ public RagIndexStatus markIndexingCompleted(Project project, String branchName, @Transactional public RagIndexStatus markIndexingFailed(Project project, String errorMessage) { - Optional existingOpt = ragIndexStatusRepository.findByProjectId(project.getId()); + return markIndexingFailed(project, errorMessage, null); + } + + @Transactional + public RagIndexStatus markIndexingFailed( + Project project, + String errorMessage, + Long expectedActiveJobId) { + Optional existingOpt = + ragIndexStatusRepository.findByProjectIdForUpdate(project.getId()); RagIndexStatus status; if (existingOpt.isPresent()) { status = existingOpt.get(); + if (!ownsStatus(status, expectedActiveJobId, "fail full indexing")) { + return status; + } status.setStatus(RagIndexingStatus.FAILED); status.setErrorMessage(errorMessage); + status.setActiveJobId(null); } else { status = new RagIndexStatus(); status.setProject(project); @@ -102,6 +140,7 @@ public RagIndexStatus markIndexingFailed(Project project, String errorMessage) { status.setStatus(RagIndexingStatus.FAILED); status.setErrorMessage(errorMessage); status.setCollectionName(generateCollectionName(project)); + status.setActiveJobId(null); } status = ragIndexStatusRepository.save(status); @@ -118,14 +157,22 @@ public RagIndexStatus markIndexingFailed(Project project, String errorMessage) { */ @Transactional public boolean markIndexingHeartbeat(Project project) { + return markIndexingHeartbeat(project, null); + } + + @Transactional + public boolean markIndexingHeartbeat(Project project, Long expectedActiveJobId) { Optional statusOpt = - ragIndexStatusRepository.findByProjectId(project.getId()); + ragIndexStatusRepository.findByProjectIdForUpdate(project.getId()); if (statusOpt.isEmpty()) { log.warn("Ignoring RAG heartbeat without index status for project {}", project.getId()); return false; } RagIndexStatus status = statusOpt.get(); + if (!ownsStatus(status, expectedActiveJobId, "record indexing heartbeat")) { + return false; + } if (status.getStatus() != RagIndexingStatus.INDEXING && status.getStatus() != RagIndexingStatus.UPDATING) { log.debug( @@ -141,12 +188,17 @@ public boolean markIndexingHeartbeat(Project project) { } @Transactional - public RagIndexStatus markUpdatingStarted(Project project, String branchName, String commitHash) { - RagIndexStatus status = ragIndexStatusRepository.findByProjectId(project.getId()) + public RagIndexStatus markUpdatingStarted( + Project project, + String branchName, + String commitHash, + Long activeJobId) { + RagIndexStatus status = ragIndexStatusRepository.findByProjectIdForUpdate(project.getId()) .orElseThrow(() -> new IllegalStateException("Cannot update non-indexed project: " + project.getId())); status.setStatus(RagIndexingStatus.UPDATING); status.setErrorMessage(null); + status.setActiveJobId(activeJobId); status = ragIndexStatusRepository.save(status); log.info("Marked RAG indexing as UPDATING for project {} toward branch {} commit {}; " @@ -164,7 +216,22 @@ public RagIndexStatus markUpdatingStarted(Project project, String branchName, St public RagIndexStatus markUpdatingCompleted(Project project, String branchName, String commitHash, Integer addedFilesCount, Integer deletedFilesCount, Integer chunkCount) { return markUpdatingCompleted( - project, branchName, commitHash, addedFilesCount, deletedFilesCount, chunkCount, true); + project, branchName, commitHash, addedFilesCount, deletedFilesCount, + chunkCount, true, null); + } + + @Transactional + public RagIndexStatus markUpdatingCompleted( + Project project, + String branchName, + String commitHash, + Integer addedFilesCount, + Integer deletedFilesCount, + Integer chunkCount, + Long expectedActiveJobId) { + return markUpdatingCompleted( + project, branchName, commitHash, addedFilesCount, deletedFilesCount, + chunkCount, true, expectedActiveJobId); } /** @@ -176,10 +243,29 @@ public RagIndexStatus markUpdatingCompleted(Project project, String branchName, public RagIndexStatus markUpdatingCompleted(Project project, String branchName, String commitHash, Integer addedFilesCount, Integer deletedFilesCount, Integer chunkCount, boolean advanceProjectCheckpoint) { - RagIndexStatus status = ragIndexStatusRepository.findByProjectId(project.getId()) + return markUpdatingCompleted( + project, branchName, commitHash, addedFilesCount, deletedFilesCount, + chunkCount, advanceProjectCheckpoint, null); + } + + @Transactional + public RagIndexStatus markUpdatingCompleted( + Project project, + String branchName, + String commitHash, + Integer addedFilesCount, + Integer deletedFilesCount, + Integer chunkCount, + boolean advanceProjectCheckpoint, + Long expectedActiveJobId) { + RagIndexStatus status = ragIndexStatusRepository.findByProjectIdForUpdate(project.getId()) .orElseThrow( () -> new IllegalStateException("RAG index status not found for project: " + project.getId())); + if (!ownsStatus(status, expectedActiveJobId, "complete incremental indexing")) { + return status; + } + status.setStatus(RagIndexingStatus.INDEXED); if (advanceProjectCheckpoint) { status.setIndexedBranch(branchName); @@ -197,6 +283,7 @@ public RagIndexStatus markUpdatingCompleted(Project project, String branchName, status.setLastIndexedAt(OffsetDateTime.now()); status.setErrorMessage(null); + status.setActiveJobId(null); // Reset failed incremental count on successful update status.resetFailedIncrementalCount(); @@ -214,15 +301,28 @@ public RagIndexStatus markUpdatingCompleted(Project project, String branchName, */ @Transactional public RagIndexStatus markIncrementalUpdateFailed(Project project, String errorMessage) { - RagIndexStatus status = ragIndexStatusRepository.findByProjectId(project.getId()) + return markIncrementalUpdateFailed(project, errorMessage, null); + } + + @Transactional + public RagIndexStatus markIncrementalUpdateFailed( + Project project, + String errorMessage, + Long expectedActiveJobId) { + RagIndexStatus status = ragIndexStatusRepository.findByProjectIdForUpdate(project.getId()) .orElseThrow( () -> new IllegalStateException("RAG index status not found for project: " + project.getId())); + if (!ownsStatus(status, expectedActiveJobId, "fail incremental indexing")) { + return status; + } + // Restore the usable terminal state and retain the last completed // branch/commit checkpoint. The attempted commit is never published. status.setStatus(RagIndexingStatus.INDEXED); status.setErrorMessage("Incremental update failed: " + errorMessage); status.incrementFailedIncrementalCount(); + status.setActiveJobId(null); status = ragIndexStatusRepository.save(status); log.warn("Marked RAG incremental update as FAILED for project {} (failure count: {}): {}", @@ -248,4 +348,21 @@ private String generateCollectionName(Project project) { String projectName = project.getName().replaceAll("[^a-zA-Z0-9_-]", "_"); return String.format("%s_%s", workspace, projectName).toLowerCase(); } + + private boolean ownsStatus( + RagIndexStatus status, + Long expectedActiveJobId, + String transition) { + if (Objects.equals(status.getActiveJobId(), expectedActiveJobId)) { + return true; + } + log.info( + "Ignoring stale RAG status transition '{}' for project {}: " + + "expected owner {}, current owner {}", + transition, + status.getProject() != null ? status.getProject().getId() : null, + expectedActiveJobId, + status.getActiveJobId()); + return false; + } } diff --git a/java-ecosystem/libs/rag-engine/src/main/java/org/rostilos/codecrow/ragengine/service/RagOperationsServiceImpl.java b/java-ecosystem/libs/rag-engine/src/main/java/org/rostilos/codecrow/ragengine/service/RagOperationsServiceImpl.java index e37f2e84..88c0da8f 100644 --- a/java-ecosystem/libs/rag-engine/src/main/java/org/rostilos/codecrow/ragengine/service/RagOperationsServiceImpl.java +++ b/java-ecosystem/libs/rag-engine/src/main/java/org/rostilos/codecrow/ragengine/service/RagOperationsServiceImpl.java @@ -178,6 +178,7 @@ public boolean triggerIncrementalUpdate( } boolean exactGenerationMode = usesExactGenerations(project); + boolean tracksProjectStatus = branchName.equals(getBaseBranch(project)); RagBranchIndexKind exactGenerationKind = exactGenerationMode ? indexKind(project, branchName) : null; boolean publishBranchAlias = exactGenerationKind == RagBranchIndexKind.PRIMARY @@ -247,7 +248,10 @@ public boolean triggerIncrementalUpdate( "message", "Updating RAG index with " + (addedOrModifiedSize + deletedFiles.size()) + " changed files")); - ragIndexTrackingService.markUpdatingStarted(project, branchName, commitHash); + if (tracksProjectStatus) { + ragIndexTrackingService.markUpdatingStarted( + project, branchName, commitHash, job != null ? job.getId() : null); + } log.info("Performing RAG incremental update for project={}, branch={}, commit={}", project.getId(), branchName, commitHash); @@ -276,7 +280,8 @@ public boolean triggerIncrementalUpdate( sourceGeneration.getRepresentationFingerprint()); branchIndexRegistryService.startBuild( branchBuild.operation().getId(), - job != null ? job.getId() : null); + job != null ? job.getId() : null, + ragLockKey.get()); } } @@ -294,7 +299,9 @@ public boolean triggerIncrementalUpdate( exactGenerationKind, ragConfig.includePatterns(), ragConfig.excludePatterns(), - job != null ? job.getId() : null); + job != null ? job.getId() : null, + ragLockKey.get(), + null); } else if (exactGenerationMode) { result = incrementalRagUpdateService.performIncrementalUpdate( project, @@ -362,14 +369,16 @@ public boolean triggerIncrementalUpdate( chunkCount = ((Number) result.get("chunk_count")).intValue(); } - ragIndexTrackingService.markUpdatingCompleted( - project, - branchName, - commitHash, - newlyAddedFilesCount != null ? newlyAddedFilesCount : 0, - filesDeleted, - chunkCount, - branchName.equals(getBaseBranch(project))); + if (tracksProjectStatus) { + ragIndexTrackingService.markUpdatingCompleted( + project, + branchName, + commitHash, + newlyAddedFilesCount != null ? newlyAddedFilesCount : 0, + filesDeleted, + chunkCount, + job != null ? job.getId() : null); + } // Track branch index for deleted files trackBranchIndex(project, branchName, commitHash, deletedFiles); @@ -397,7 +406,10 @@ public boolean triggerIncrementalUpdate( // Use markIncrementalUpdateFailed (keeps status INDEXED, increments failure counter) // NOT markIndexingFailed which would set status to FAILED and permanently block // all future incremental updates even though the base index is still valid. - ragIndexTrackingService.markIncrementalUpdateFailed(project, e.getMessage()); + if (tracksProjectStatus) { + ragIndexTrackingService.markIncrementalUpdateFailed( + project, e.getMessage(), job != null ? job.getId() : null); + } log.error("RAG incremental update failed", e); if (job != null) { analysisJobService.error(job, "rag_error", "RAG incremental update failed: " + e.getMessage()); diff --git a/java-ecosystem/libs/rag-engine/src/main/java/org/rostilos/codecrow/ragengine/service/VcsRagIndexingService.java b/java-ecosystem/libs/rag-engine/src/main/java/org/rostilos/codecrow/ragengine/service/VcsRagIndexingService.java index 92be1916..c3496ff6 100644 --- a/java-ecosystem/libs/rag-engine/src/main/java/org/rostilos/codecrow/ragengine/service/VcsRagIndexingService.java +++ b/java-ecosystem/libs/rag-engine/src/main/java/org/rostilos/codecrow/ragengine/service/VcsRagIndexingService.java @@ -200,7 +200,8 @@ private Map performIndexing( return Map.of("status", "error", "message", errorMsg); } - ragIndexTrackingService.markIndexingStarted(project, branch, commitHash); + ragIndexTrackingService.markIndexingStarted( + project, branch, commitHash, job != null ? job.getId() : null); String downloadMsg = "Downloading repository archive..."; messageConsumer.accept(Map.of( @@ -316,7 +317,8 @@ private Map performIndexing( } catch (Exception e) { log.error("RAG indexing failed for project {}", project.getName(), e); - ragIndexTrackingService.markIndexingFailed(project, e.getMessage()); + ragIndexTrackingService.markIndexingFailed( + project, e.getMessage(), job != null ? job.getId() : null); analysisLockService.releaseLock(lockKey); messageConsumer.accept(Map.of( @@ -473,7 +475,8 @@ public void pollRagIndexingJobAsync( + jobId; break; } - ragIndexTrackingService.markIndexingHeartbeat(project); + ragIndexTrackingService.markIndexingHeartbeat( + project, job != null ? job.getId() : null); continue; } errorMessage = "RAG indexing produced no worker heartbeat for " @@ -500,7 +503,8 @@ public void pollRagIndexingJobAsync( // Redis activity keeps the lease alive; persist the same // activity on the existing status row so long-running, // healthy indexes do not look stalled to operators. - ragIndexTrackingService.markIndexingHeartbeat(project); + ragIndexTrackingService.markIndexingHeartbeat( + project, job != null ? job.getId() : null); String state = String.valueOf(event.getOrDefault("stage", event.getOrDefault("state", "indexing"))); @@ -551,7 +555,13 @@ public void pollRagIndexingJobAsync( // Update Job and Project Tracking if (success) { - ragIndexTrackingService.markIndexingCompleted(project, branch, commitHash, filesIndexed, chunkCount); + ragIndexTrackingService.markIndexingCompleted( + project, + branch, + commitHash, + filesIndexed, + chunkCount, + job != null ? job.getId() : null); String completeMsg = "RAG indexing completed successfully. Files indexed: " + (filesIndexed != null ? filesIndexed : 0); if (job != null) { @@ -561,8 +571,10 @@ public void pollRagIndexingJobAsync( log.info("RAG indexing completed for project {} branch {}: {} files", project.getName(), branch, filesIndexed); } else { - ragIndexTrackingService.markIndexingFailed(project, - errorMessage != null ? errorMessage : "Unknown Error"); + ragIndexTrackingService.markIndexingFailed( + project, + errorMessage != null ? errorMessage : "Unknown Error", + job != null ? job.getId() : null); if (job != null) { jobService.logToJob(job, JobLogLevel.ERROR, "error", "RAG indexing failed: " + errorMessage); jobService.failJob(job, "RAG indexing failed: " + errorMessage); diff --git a/java-ecosystem/libs/rag-engine/src/test/java/org/rostilos/codecrow/ragengine/branch/BranchIndexGenerationBuildServiceTest.java b/java-ecosystem/libs/rag-engine/src/test/java/org/rostilos/codecrow/ragengine/branch/BranchIndexGenerationBuildServiceTest.java index e9e14f20..4f19bbfc 100644 --- a/java-ecosystem/libs/rag-engine/src/test/java/org/rostilos/codecrow/ragengine/branch/BranchIndexGenerationBuildServiceTest.java +++ b/java-ecosystem/libs/rag-engine/src/test/java/org/rostilos/codecrow/ragengine/branch/BranchIndexGenerationBuildServiceTest.java @@ -92,7 +92,7 @@ project, new VcsConnection(), "provider-workspace", "repo", assertThat(result).containsEntry( "generation_manifest_sha256", "manifest-400"); - verify(registryService).startBuild(30L, 77L); + verify(registryService).startBuild(30L, 77L, null); verify(registryService).publish(30L, "manifest-400", 231, 400); verify(pipelineClient).publishGenerationAliases( "workspace", "namespace", "develop", "develop-400", diff --git a/java-ecosystem/libs/rag-engine/src/test/java/org/rostilos/codecrow/ragengine/branch/RagBranchOperatorAliasReconciliationServiceTest.java b/java-ecosystem/libs/rag-engine/src/test/java/org/rostilos/codecrow/ragengine/branch/RagBranchOperatorAliasReconciliationServiceTest.java index 96896140..7a63647e 100644 --- a/java-ecosystem/libs/rag-engine/src/test/java/org/rostilos/codecrow/ragengine/branch/RagBranchOperatorAliasReconciliationServiceTest.java +++ b/java-ecosystem/libs/rag-engine/src/test/java/org/rostilos/codecrow/ragengine/branch/RagBranchOperatorAliasReconciliationServiceTest.java @@ -1,11 +1,7 @@ package org.rostilos.codecrow.ragengine.branch; import org.junit.jupiter.api.Test; -import org.rostilos.codecrow.core.model.project.Project; -import org.rostilos.codecrow.core.model.rag.RagBranchIndex; -import org.rostilos.codecrow.core.model.rag.RagBranchIndexGeneration; import org.rostilos.codecrow.core.model.rag.RagBranchIndexKind; -import org.rostilos.codecrow.core.model.workspace.Workspace; import org.rostilos.codecrow.core.persistence.repository.rag.RagBranchIndexRepository; import org.rostilos.codecrow.ragengine.client.RagPipelineClient; @@ -22,10 +18,9 @@ void restoresReadableAliasesForDurableAndPrimaryGenerationsOnly() throws Excepti RagBranchOperatorAliasReconciliationService service = new RagBranchOperatorAliasReconciliationService(repository, client); - RagBranchIndex primary = index("main", RagBranchIndexKind.PRIMARY, "main-target"); - RagBranchIndex durable = index("develop", RagBranchIndexKind.DURABLE, "develop-target"); - RagBranchIndex transientIndex = index("release", RagBranchIndexKind.TRANSIENT, "release-target"); - when(repository.findAll()).thenReturn(List.of(primary, durable, transientIndex)); + var primary = candidate("main", RagBranchIndexKind.PRIMARY, "main-target"); + var durable = candidate("develop", RagBranchIndexKind.DURABLE, "develop-target"); + when(repository.findOperatorAliasCandidates()).thenReturn(List.of(primary, durable)); service.reconcileActiveGenerationAliases(); @@ -36,24 +31,18 @@ void restoresReadableAliasesForDurableAndPrimaryGenerationsOnly() throws Excepti verifyNoMoreInteractions(client); } - private static RagBranchIndex index( + private static RagBranchIndexRepository.OperatorAliasCandidate candidate( String branch, RagBranchIndexKind kind, String target) { - Workspace workspace = mock(Workspace.class); - when(workspace.getName()).thenReturn("workspace"); - Project project = mock(Project.class); - when(project.getWorkspace()).thenReturn(workspace); - when(project.getNamespace()).thenReturn("project"); - when(project.getId()).thenReturn(1L); - RagBranchIndexGeneration generation = mock(RagBranchIndexGeneration.class); - when(generation.getRevision()).thenReturn("revision"); - when(generation.getCollectionName()).thenReturn(target); - RagBranchIndex index = mock(RagBranchIndex.class); - when(index.getProject()).thenReturn(project); - when(index.getBranchName()).thenReturn(branch); - when(index.getIndexKind()).thenReturn(kind); - when(index.getActiveGeneration()).thenReturn(generation); - return index; + var candidate = mock(RagBranchIndexRepository.OperatorAliasCandidate.class); + when(candidate.getProjectId()).thenReturn(1L); + when(candidate.getWorkspaceName()).thenReturn("workspace"); + when(candidate.getProjectNamespace()).thenReturn("project"); + when(candidate.getBranchName()).thenReturn(branch); + when(candidate.getRevision()).thenReturn("revision"); + when(candidate.getCollectionName()).thenReturn(target); + when(candidate.getIndexKind()).thenReturn(kind); + return candidate; } } diff --git a/java-ecosystem/libs/rag-engine/src/test/java/org/rostilos/codecrow/ragengine/branch/RagIndexOperationRecoveryServiceTest.java b/java-ecosystem/libs/rag-engine/src/test/java/org/rostilos/codecrow/ragengine/branch/RagIndexOperationRecoveryServiceTest.java index 404706b4..fa221152 100644 --- a/java-ecosystem/libs/rag-engine/src/test/java/org/rostilos/codecrow/ragengine/branch/RagIndexOperationRecoveryServiceTest.java +++ b/java-ecosystem/libs/rag-engine/src/test/java/org/rostilos/codecrow/ragengine/branch/RagIndexOperationRecoveryServiceTest.java @@ -1,31 +1,171 @@ package org.rostilos.codecrow.ragengine.branch; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.rostilos.codecrow.analysisapi.rag.RagOperationsService; +import org.rostilos.codecrow.analysisengine.service.AnalysisLockService; +import org.rostilos.codecrow.core.model.analysis.RagIndexStatus; +import org.rostilos.codecrow.core.model.analysis.RagIndexingStatus; +import org.rostilos.codecrow.core.model.job.Job; +import org.rostilos.codecrow.core.model.project.Project; import org.rostilos.codecrow.core.model.rag.RagIndexOperation; +import org.rostilos.codecrow.core.persistence.repository.project.ProjectRepository; +import org.rostilos.codecrow.core.service.JobService; import org.rostilos.codecrow.ragengine.service.RagBranchIndexRegistryService; +import org.rostilos.codecrow.ragengine.service.RagIndexTrackingService; import java.time.OffsetDateTime; import java.util.List; +import java.util.Optional; -import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; class RagIndexOperationRecoveryServiceTest { + private RagBranchIndexRegistryService registry; + private ProjectRepository projects; + private JobService jobs; + private RagIndexTrackingService tracking; + private RagOperationsService ragOperations; + private AnalysisLockService locks; + private RagIndexOperationRecoveryService recovery; + private RagIndexOperation operation; + private Project project; + + @BeforeEach + void setUp() { + registry = mock(RagBranchIndexRegistryService.class); + projects = mock(ProjectRepository.class); + jobs = mock(JobService.class); + tracking = mock(RagIndexTrackingService.class); + ragOperations = mock(RagOperationsService.class); + locks = mock(AnalysisLockService.class); + recovery = new RagIndexOperationRecoveryService( + registry, projects, jobs, tracking, ragOperations, locks, 30); + + project = mock(Project.class); + when(project.getId()).thenReturn(42L); + operation = mock(RagIndexOperation.class); + when(operation.getId()).thenReturn(81L); + when(operation.getProject()).thenReturn(project); + when(operation.getBranchName()).thenReturn("main"); + when(operation.getToRevision()).thenReturn("commit-a"); + when(operation.getJobId()).thenReturn(91L); + when(operation.getAnalysisLockKey()).thenReturn("rag-lock-owner-91"); + when(registry.findFailedOperationsWithActiveProjections()) + .thenReturn(List.of()); + } + @Test - void abandonedPersistedOperationBecomesTerminalWithUsefulDiagnostic() { - RagBranchIndexRegistryService registry = mock(RagBranchIndexRegistryService.class); - RagIndexOperation operation = new RagIndexOperation(); - operation.setId(81L); - operation.setBranchName("develop"); + void abandonedOperationTerminalizesJobPrimaryStatusAndLock() { + Job job = new Job(); + RagIndexStatus status = new RagIndexStatus(); + status.setStatus(RagIndexingStatus.INDEXING); + status.setActiveJobId(91L); when(registry.findRecoverableOperations(any(OffsetDateTime.class))) .thenReturn(List.of(operation)); + when(registry.failIfAbandoned(eq(81L), any(OffsetDateTime.class), anyString())) + .thenReturn(true); + when(jobs.findById(91L)).thenReturn(Optional.of(job)); + when(projects.findByIdWithFullDetails(42L)).thenReturn(Optional.of(project)); + when(ragOperations.getBaseBranch(project)).thenReturn("main"); + when(tracking.getIndexStatus(project)).thenReturn(Optional.of(status)); + recovery.failAbandonedOperations(); - new RagIndexOperationRecoveryService(registry, 30) - .failAbandonedOperations(); - - verify(registry).fail(eq(81L), argThat(message -> + verify(registry).failIfAbandoned(eq(81L), any(OffsetDateTime.class), argThat(message -> message.contains("stopped heartbeating") && message.contains("previous active generation was preserved"))); + verify(jobs).failJob(eq(job), contains("stopped heartbeating")); + verify(tracking).markIndexingFailed( + eq(project), contains("stopped heartbeating"), eq(91L)); + verify(locks).releaseLock("rag-lock-owner-91"); + } + + @Test + void abandonedIncrementalOperationRestoresTheUsableIndexedStatus() { + RagIndexStatus status = new RagIndexStatus(); + status.setStatus(RagIndexingStatus.UPDATING); + status.setActiveJobId(91L); + when(registry.findRecoverableOperations(any(OffsetDateTime.class))) + .thenReturn(List.of(operation)); + when(registry.failIfAbandoned(eq(81L), any(OffsetDateTime.class), anyString())) + .thenReturn(true); + when(jobs.findById(91L)).thenReturn(Optional.empty()); + when(projects.findByIdWithFullDetails(42L)).thenReturn(Optional.of(project)); + when(ragOperations.getBaseBranch(project)).thenReturn("main"); + when(tracking.getIndexStatus(project)).thenReturn(Optional.of(status)); + + recovery.failAbandonedOperations(); + + verify(tracking).markIncrementalUpdateFailed( + eq(project), contains("stopped heartbeating"), eq(91L)); + verify(tracking, never()).markIndexingFailed(any(), anyString(), any()); + } + + @Test + void newerLiveOperationPreservesStatusWhileOldExactLockIsReleased() { + Job job = new Job(); + RagIndexStatus status = new RagIndexStatus(); + status.setStatus(RagIndexingStatus.INDEXING); + status.setActiveJobId(92L); + when(registry.findRecoverableOperations(any(OffsetDateTime.class))) + .thenReturn(List.of(operation)); + when(registry.failIfAbandoned(eq(81L), any(OffsetDateTime.class), anyString())) + .thenReturn(true); + when(jobs.findById(91L)).thenReturn(Optional.of(job)); + when(projects.findByIdWithFullDetails(42L)).thenReturn(Optional.of(project)); + when(ragOperations.getBaseBranch(project)).thenReturn("main"); + when(tracking.getIndexStatus(project)).thenReturn(Optional.of(status)); + + recovery.failAbandonedOperations(); + + verify(jobs).failJob(eq(job), contains("stopped heartbeating")); + verify(tracking, never()).markIndexingFailed(any(), anyString(), any()); + verify(locks).releaseLock("rag-lock-owner-91"); + } + + @Test + void alreadyFailedOperationRepairsOlderProjectionDrift() { + Job job = new Job(); + RagIndexStatus status = new RagIndexStatus(); + status.setStatus(RagIndexingStatus.INDEXING); + status.setActiveJobId(91L); + when(operation.getErrorMessage()).thenReturn("producer was abandoned"); + when(registry.findRecoverableOperations(any(OffsetDateTime.class))) + .thenReturn(List.of()); + when(registry.findFailedOperationsWithActiveProjections()) + .thenReturn(List.of(operation)); + when(jobs.findById(91L)).thenReturn(Optional.of(job)); + when(projects.findByIdWithFullDetails(42L)).thenReturn(Optional.of(project)); + when(ragOperations.getBaseBranch(project)).thenReturn("main"); + when(tracking.getIndexStatus(project)).thenReturn(Optional.of(status)); + + recovery.failAbandonedOperations(); + + verify(registry, never()).failIfAbandoned(anyLong(), any(), anyString()); + verify(jobs).failJob(job, "producer was abandoned"); + verify(tracking).markIndexingFailed(project, "producer was abandoned", 91L); + verify(locks).releaseLock("rag-lock-owner-91"); + } + + @Test + void alreadyFailedOperationPreservesStatusOwnedByANewerJob() { + RagIndexStatus status = new RagIndexStatus(); + status.setStatus(RagIndexingStatus.INDEXING); + status.setActiveJobId(92L); + when(registry.findRecoverableOperations(any(OffsetDateTime.class))) + .thenReturn(List.of()); + when(registry.findFailedOperationsWithActiveProjections()) + .thenReturn(List.of(operation)); + when(projects.findByIdWithFullDetails(42L)).thenReturn(Optional.of(project)); + when(ragOperations.getBaseBranch(project)).thenReturn("main"); + when(tracking.getIndexStatus(project)).thenReturn(Optional.of(status)); + + recovery.failAbandonedOperations(); + + verify(tracking, never()).markIndexingFailed(any(), anyString(), any()); + verify(tracking, never()).markIncrementalUpdateFailed(any(), anyString(), any()); + verify(locks).releaseLock("rag-lock-owner-91"); } } diff --git a/java-ecosystem/libs/rag-engine/src/test/java/org/rostilos/codecrow/ragengine/service/RagBranchIndexRegistryServiceTest.java b/java-ecosystem/libs/rag-engine/src/test/java/org/rostilos/codecrow/ragengine/service/RagBranchIndexRegistryServiceTest.java index 7b5b3052..59810b9f 100644 --- a/java-ecosystem/libs/rag-engine/src/test/java/org/rostilos/codecrow/ragengine/service/RagBranchIndexRegistryServiceTest.java +++ b/java-ecosystem/libs/rag-engine/src/test/java/org/rostilos/codecrow/ragengine/service/RagBranchIndexRegistryServiceTest.java @@ -12,6 +12,7 @@ import org.rostilos.codecrow.core.persistence.repository.rag.RagIndexOperationRepository; import org.springframework.test.util.ReflectionTestUtils; +import java.time.OffsetDateTime; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; @@ -44,14 +45,14 @@ void setUp() { } return value; }); - when(generationRepository.save(any())).thenAnswer(invocation -> { + lenient().when(generationRepository.save(any())).thenAnswer(invocation -> { RagBranchIndexGeneration value = invocation.getArgument(0); if (value.getId() == null) { value.setId(20L); } return value; }); - when(operationRepository.save(any())).thenAnswer(invocation -> { + lenient().when(operationRepository.save(any())).thenAnswer(invocation -> { RagIndexOperation value = invocation.getArgument(0); if (value.getId() == null) { value.setId(30L); @@ -103,11 +104,11 @@ void publishesNewGenerationAndSupersedesPreviousOneAtomically() { var registration = service.registerBuild( project, "develop", RagBranchIndexKind.DURABLE, "develop-400", "develop-401", "representation"); - when(operationRepository.findById(30L)).thenReturn(Optional.of(registration.operation())); + when(operationRepository.findByIdForUpdate(30L)).thenReturn(Optional.of(registration.operation())); when(branchIndexRepository.findByIdForPublication(10L)) .thenReturn(Optional.of(branchIndex)); - service.startBuild(30L, 99L); + service.startBuild(30L, 99L, "rag-lock-owner-99"); RagBranchIndexGeneration published = service.publish(30L, "manifest-401", 501, 1504); assertThat(previous.getStatus()).isEqualTo(RagBranchIndexGenerationStatus.SUPERSEDED); @@ -117,6 +118,8 @@ void publishesNewGenerationAndSupersedesPreviousOneAtomically() { assertThat(registration.operation().getStatus()).isEqualTo(RagIndexOperationStatus.SUCCEEDED); assertThat(registration.operation().getAttemptCount()).isEqualTo(1); assertThat(registration.operation().getJobId()).isEqualTo(99L); + assertThat(registration.operation().getAnalysisLockKey()) + .isEqualTo("rag-lock-owner-99"); } @Test @@ -141,7 +144,7 @@ void completedOlderGenerationDoesNotRegressNewerDesiredRevision() { operation.setId(30L); operation.setGeneration(late); operation.start(); - when(operationRepository.findById(30L)).thenReturn(Optional.of(operation)); + when(operationRepository.findByIdForUpdate(30L)).thenReturn(Optional.of(operation)); when(branchIndexRepository.findByIdForPublication(10L)) .thenReturn(Optional.of(branchIndex)); @@ -176,7 +179,7 @@ void failedReplacementKeepsLastVerifiedGenerationAvailable() { var registration = service.registerBuild( project, "master", RagBranchIndexKind.PRIMARY, "master-100", "master-101", "representation"); - when(operationRepository.findById(30L)).thenReturn(Optional.of(registration.operation())); + when(operationRepository.findByIdForUpdate(30L)).thenReturn(Optional.of(registration.operation())); when(branchIndexRepository.findByIdForPublication(10L)) .thenReturn(Optional.of(branchIndex)); @@ -206,7 +209,7 @@ void failedIdempotentOperationCanRetryWithoutCreatingDuplicateGeneration() { operation.setId(30L); operation.setGeneration(generation); operation.fail("worker restarted"); - when(operationRepository.findById(30L)).thenReturn(Optional.of(operation)); + when(operationRepository.findByIdForUpdate(30L)).thenReturn(Optional.of(operation)); service.startBuild(30L, 91L); @@ -236,7 +239,7 @@ void lateFailureDoesNotOverwriteNewerDesiredRevisionState() { operation.setId(30L); operation.setGeneration(late); operation.start(); - when(operationRepository.findById(30L)).thenReturn(Optional.of(operation)); + when(operationRepository.findByIdForUpdate(30L)).thenReturn(Optional.of(operation)); when(branchIndexRepository.findByIdForPublication(10L)) .thenReturn(Optional.of(branchIndex)); @@ -250,4 +253,22 @@ void lateFailureDoesNotOverwriteNewerDesiredRevisionState() { assertThat(branchIndex.getErrorMessage()).isNull(); verify(branchIndexRepository, never()).save(branchIndex); } + + @Test + void abandonmentClaimRechecksAHeartbeatUnderTheOperationLock() { + RagIndexOperation operation = new RagIndexOperation( + project, "develop", "develop-400", "develop-401", "heartbeat-key"); + operation.setId(30L); + operation.start(); + operation.setUpdatedAt(OffsetDateTime.now()); + when(operationRepository.findByIdForUpdate(30L)).thenReturn(Optional.of(operation)); + + boolean failed = service.failIfAbandoned( + 30L, OffsetDateTime.now().minusMinutes(30), "producer abandoned"); + + assertThat(failed).isFalse(); + assertThat(operation.getStatus()).isEqualTo(RagIndexOperationStatus.RUNNING); + verifyNoInteractions(branchIndexRepository); + verify(operationRepository, never()).save(operation); + } } diff --git a/java-ecosystem/libs/rag-engine/src/test/java/org/rostilos/codecrow/ragengine/service/RagIndexTrackingServiceTest.java b/java-ecosystem/libs/rag-engine/src/test/java/org/rostilos/codecrow/ragengine/service/RagIndexTrackingServiceTest.java index 3885eb5c..ae1fce99 100644 --- a/java-ecosystem/libs/rag-engine/src/test/java/org/rostilos/codecrow/ragengine/service/RagIndexTrackingServiceTest.java +++ b/java-ecosystem/libs/rag-engine/src/test/java/org/rostilos/codecrow/ragengine/service/RagIndexTrackingServiceTest.java @@ -88,10 +88,10 @@ void testGetIndexStatus_NotFound() { @Test void testMarkIndexingStarted_NewStatus() { - when(ragIndexStatusRepository.findByProjectId(100L)).thenReturn(Optional.empty()); + when(ragIndexStatusRepository.findByProjectIdForUpdate(100L)).thenReturn(Optional.empty()); when(ragIndexStatusRepository.save(any(RagIndexStatus.class))).thenAnswer(i -> i.getArgument(0)); - RagIndexStatus result = service.markIndexingStarted(testProject, "main", "abc123"); + RagIndexStatus result = service.markIndexingStarted(testProject, "main", "abc123", 91L); ArgumentCaptor captor = ArgumentCaptor.forClass(RagIndexStatus.class); verify(ragIndexStatusRepository).save(captor.capture()); @@ -103,6 +103,7 @@ void testMarkIndexingStarted_NewStatus() { assertThat(saved.getIndexedCommitHash()).isEqualTo("abc123"); assertThat(saved.getWorkspaceName()).isEqualTo("test-workspace"); assertThat(saved.getProjectName()).isEqualTo("test-project"); + assertThat(saved.getActiveJobId()).isEqualTo(91L); } @Test @@ -112,10 +113,10 @@ void testMarkIndexingStarted_ExistingStatus() { existing.setStatus(RagIndexingStatus.FAILED); existing.setErrorMessage("Previous error"); - when(ragIndexStatusRepository.findByProjectId(100L)).thenReturn(Optional.of(existing)); + when(ragIndexStatusRepository.findByProjectIdForUpdate(100L)).thenReturn(Optional.of(existing)); when(ragIndexStatusRepository.save(any(RagIndexStatus.class))).thenAnswer(i -> i.getArgument(0)); - service.markIndexingStarted(testProject, "develop", "xyz789"); + service.markIndexingStarted(testProject, "develop", "xyz789", 92L); ArgumentCaptor captor = ArgumentCaptor.forClass(RagIndexStatus.class); verify(ragIndexStatusRepository).save(captor.capture()); @@ -125,6 +126,7 @@ void testMarkIndexingStarted_ExistingStatus() { assertThat(saved.getIndexedBranch()).isEqualTo("develop"); assertThat(saved.getIndexedCommitHash()).isEqualTo("xyz789"); assertThat(saved.getErrorMessage()).isNull(); + assertThat(saved.getActiveJobId()).isEqualTo(92L); } @Test @@ -132,11 +134,13 @@ void testMarkIndexingCompleted() { RagIndexStatus existing = new RagIndexStatus(); existing.setProject(testProject); existing.setStatus(RagIndexingStatus.INDEXING); + existing.setActiveJobId(91L); - when(ragIndexStatusRepository.findByProjectId(100L)).thenReturn(Optional.of(existing)); + when(ragIndexStatusRepository.findByProjectIdForUpdate(100L)).thenReturn(Optional.of(existing)); when(ragIndexStatusRepository.save(any(RagIndexStatus.class))).thenAnswer(i -> i.getArgument(0)); - RagIndexStatus result = service.markIndexingCompleted(testProject, "main", "abc123", 150, null); + RagIndexStatus result = service.markIndexingCompleted( + testProject, "main", "abc123", 150, null, 91L); ArgumentCaptor captor = ArgumentCaptor.forClass(RagIndexStatus.class); verify(ragIndexStatusRepository).save(captor.capture()); @@ -148,13 +152,15 @@ void testMarkIndexingCompleted() { assertThat(saved.getTotalFilesIndexed()).isEqualTo(150); assertThat(saved.getLastIndexedAt()).isNotNull(); assertThat(saved.getErrorMessage()).isNull(); + assertThat(saved.getActiveJobId()).isNull(); } @Test void testMarkIndexingCompleted_ThrowsWhenNotFound() { - when(ragIndexStatusRepository.findByProjectId(100L)).thenReturn(Optional.empty()); + when(ragIndexStatusRepository.findByProjectIdForUpdate(100L)).thenReturn(Optional.empty()); - assertThatThrownBy(() -> service.markIndexingCompleted(testProject, "main", "abc123", 150, null)) + assertThatThrownBy(() -> service.markIndexingCompleted( + testProject, "main", "abc123", 150, null, 91L)) .isInstanceOf(IllegalStateException.class) .hasMessageContaining("RAG index status not found"); } @@ -165,7 +171,7 @@ void testMarkIndexingFailed_ExistingStatus() { existing.setProject(testProject); existing.setStatus(RagIndexingStatus.INDEXING); - when(ragIndexStatusRepository.findByProjectId(100L)).thenReturn(Optional.of(existing)); + when(ragIndexStatusRepository.findByProjectIdForUpdate(100L)).thenReturn(Optional.of(existing)); when(ragIndexStatusRepository.save(any(RagIndexStatus.class))).thenAnswer(i -> i.getArgument(0)); service.markIndexingFailed(testProject, "Test error message"); @@ -180,7 +186,7 @@ void testMarkIndexingFailed_ExistingStatus() { @Test void testMarkIndexingFailed_NewStatus() { - when(ragIndexStatusRepository.findByProjectId(100L)).thenReturn(Optional.empty()); + when(ragIndexStatusRepository.findByProjectIdForUpdate(100L)).thenReturn(Optional.empty()); when(ragIndexStatusRepository.save(any(RagIndexStatus.class))).thenAnswer(i -> i.getArgument(0)); service.markIndexingFailed(testProject, "New error"); @@ -202,7 +208,7 @@ void testMarkIndexingHeartbeat_RefreshesLiveStatusOnly() { existing.setUpdatedAt(OffsetDateTime.now().minusMinutes(5)); OffsetDateTime previousActivity = existing.getUpdatedAt(); - when(ragIndexStatusRepository.findByProjectId(100L)) + when(ragIndexStatusRepository.findByProjectIdForUpdate(100L)) .thenReturn(Optional.of(existing)); when(ragIndexStatusRepository.save(any(RagIndexStatus.class))) .thenAnswer(i -> i.getArgument(0)); @@ -221,7 +227,7 @@ void testMarkIndexingHeartbeat_RefreshesIncrementalUpdate() { existing.setUpdatedAt(OffsetDateTime.now().minusMinutes(5)); OffsetDateTime previousActivity = existing.getUpdatedAt(); - when(ragIndexStatusRepository.findByProjectId(100L)) + when(ragIndexStatusRepository.findByProjectIdForUpdate(100L)) .thenReturn(Optional.of(existing)); when(ragIndexStatusRepository.save(any(RagIndexStatus.class))) .thenAnswer(i -> i.getArgument(0)); @@ -232,6 +238,23 @@ void testMarkIndexingHeartbeat_RefreshesIncrementalUpdate() { verify(ragIndexStatusRepository).save(existing); } + @Test + void staleHeartbeatCannotRefreshANewerJobOwner() { + RagIndexStatus existing = new RagIndexStatus(); + existing.setProject(testProject); + existing.setStatus(RagIndexingStatus.INDEXING); + existing.setActiveJobId(92L); + OffsetDateTime previousActivity = OffsetDateTime.now().minusMinutes(5); + existing.setUpdatedAt(previousActivity); + when(ragIndexStatusRepository.findByProjectIdForUpdate(100L)) + .thenReturn(Optional.of(existing)); + + assertThat(service.markIndexingHeartbeat(testProject, 91L)).isFalse(); + assertThat(existing.getUpdatedAt()).isEqualTo(previousActivity); + assertThat(existing.getActiveJobId()).isEqualTo(92L); + verify(ragIndexStatusRepository, never()).save(any()); + } + @Test void testMarkIndexingHeartbeat_DoesNotMutateTerminalStatus() { RagIndexStatus existing = new RagIndexStatus(); @@ -239,7 +262,7 @@ void testMarkIndexingHeartbeat_DoesNotMutateTerminalStatus() { existing.setStatus(RagIndexingStatus.INDEXED); OffsetDateTime previousActivity = existing.getUpdatedAt(); - when(ragIndexStatusRepository.findByProjectId(100L)) + when(ragIndexStatusRepository.findByProjectIdForUpdate(100L)) .thenReturn(Optional.of(existing)); assertThat(service.markIndexingHeartbeat(testProject)).isFalse(); @@ -249,7 +272,7 @@ void testMarkIndexingHeartbeat_DoesNotMutateTerminalStatus() { @Test void testMarkIndexingHeartbeat_WithoutStatusIsIgnored() { - when(ragIndexStatusRepository.findByProjectId(100L)) + when(ragIndexStatusRepository.findByProjectIdForUpdate(100L)) .thenReturn(Optional.empty()); assertThat(service.markIndexingHeartbeat(testProject)).isFalse(); @@ -272,22 +295,23 @@ void testMarkUpdatingStarted_Success() { existing.setIndexedBranch("main"); existing.setIndexedCommitHash("abc123"); - when(ragIndexStatusRepository.findByProjectId(100L)).thenReturn(Optional.of(existing)); + when(ragIndexStatusRepository.findByProjectIdForUpdate(100L)).thenReturn(Optional.of(existing)); when(ragIndexStatusRepository.save(any(RagIndexStatus.class))).thenAnswer(i -> i.getArgument(0)); - RagIndexStatus result = service.markUpdatingStarted(testProject, "main", "def456"); + RagIndexStatus result = service.markUpdatingStarted(testProject, "main", "def456", 93L); assertThat(result.getStatus()).isEqualTo(RagIndexingStatus.UPDATING); assertThat(result.getIndexedBranch()).isEqualTo("main"); assertThat(result.getIndexedCommitHash()).isEqualTo("abc123"); assertThat(result.getErrorMessage()).isNull(); + assertThat(result.getActiveJobId()).isEqualTo(93L); } @Test void testMarkUpdatingStarted_ThrowsWhenNotFound() { - when(ragIndexStatusRepository.findByProjectId(100L)).thenReturn(Optional.empty()); + when(ragIndexStatusRepository.findByProjectIdForUpdate(100L)).thenReturn(Optional.empty()); - assertThatThrownBy(() -> service.markUpdatingStarted(testProject, "main", "def456")) + assertThatThrownBy(() -> service.markUpdatingStarted(testProject, "main", "def456", 93L)) .isInstanceOf(IllegalStateException.class) .hasMessageContaining("Cannot update non-indexed project"); } @@ -299,24 +323,28 @@ void testMarkUpdatingCompleted_Success() { RagIndexStatus existing = new RagIndexStatus(); existing.setProject(testProject); existing.setStatus(RagIndexingStatus.UPDATING); + existing.setActiveJobId(93L); - when(ragIndexStatusRepository.findByProjectId(100L)).thenReturn(Optional.of(existing)); + when(ragIndexStatusRepository.findByProjectIdForUpdate(100L)).thenReturn(Optional.of(existing)); when(ragIndexStatusRepository.save(any(RagIndexStatus.class))).thenAnswer(i -> i.getArgument(0)); - RagIndexStatus result = service.markUpdatingCompleted(testProject, "main", "ghi789", null, null, null); + RagIndexStatus result = service.markUpdatingCompleted( + testProject, "main", "ghi789", null, null, null, 93L); assertThat(result.getStatus()).isEqualTo(RagIndexingStatus.INDEXED); assertThat(result.getIndexedBranch()).isEqualTo("main"); assertThat(result.getIndexedCommitHash()).isEqualTo("ghi789"); + assertThat(result.getActiveJobId()).isNull(); assertThat(result.getLastIndexedAt()).isNotNull(); assertThat(result.getErrorMessage()).isNull(); } @Test void testMarkUpdatingCompleted_ThrowsWhenNotFound() { - when(ragIndexStatusRepository.findByProjectId(100L)).thenReturn(Optional.empty()); + when(ragIndexStatusRepository.findByProjectIdForUpdate(100L)).thenReturn(Optional.empty()); - assertThatThrownBy(() -> service.markUpdatingCompleted(testProject, "main", "ghi789", null, null, null)) + assertThatThrownBy(() -> service.markUpdatingCompleted( + testProject, "main", "ghi789", null, null, null, 93L)) .isInstanceOf(IllegalStateException.class) .hasMessageContaining("RAG index status not found"); } @@ -329,7 +357,7 @@ void testMarkUpdatingCompleted_NonBaseBranchPreservesProjectCheckpoint() { existing.setIndexedBranch("main"); existing.setIndexedCommitHash("main-commit"); - when(ragIndexStatusRepository.findByProjectId(100L)) + when(ragIndexStatusRepository.findByProjectIdForUpdate(100L)) .thenReturn(Optional.of(existing)); when(ragIndexStatusRepository.save(any(RagIndexStatus.class))) .thenAnswer(i -> i.getArgument(0)); @@ -352,10 +380,12 @@ void testMarkIncrementalUpdateFailed_Success() { existing.setIndexedBranch("main"); existing.setIndexedCommitHash("abc123"); - when(ragIndexStatusRepository.findByProjectId(100L)).thenReturn(Optional.of(existing)); + existing.setActiveJobId(93L); + when(ragIndexStatusRepository.findByProjectIdForUpdate(100L)).thenReturn(Optional.of(existing)); when(ragIndexStatusRepository.save(any(RagIndexStatus.class))).thenAnswer(i -> i.getArgument(0)); - RagIndexStatus result = service.markIncrementalUpdateFailed(testProject, "timeout error"); + RagIndexStatus result = service.markIncrementalUpdateFailed( + testProject, "timeout error", 93L); assertThat(result.getStatus()).isEqualTo(RagIndexingStatus.INDEXED); assertThat(result.getIndexedBranch()).isEqualTo("main"); @@ -366,12 +396,49 @@ void testMarkIncrementalUpdateFailed_Success() { @Test void testMarkIncrementalUpdateFailed_ThrowsWhenNotFound() { - when(ragIndexStatusRepository.findByProjectId(100L)).thenReturn(Optional.empty()); + when(ragIndexStatusRepository.findByProjectIdForUpdate(100L)).thenReturn(Optional.empty()); - assertThatThrownBy(() -> service.markIncrementalUpdateFailed(testProject, "error")) + assertThatThrownBy(() -> service.markIncrementalUpdateFailed(testProject, "error", 93L)) .isInstanceOf(IllegalStateException.class); } + @Test + void staleCompletionCannotClearANewerJobOwner() { + RagIndexStatus existing = new RagIndexStatus(); + existing.setProject(testProject); + existing.setStatus(RagIndexingStatus.INDEXING); + existing.setActiveJobId(92L); + existing.setIndexedCommitHash("newer-commit"); + when(ragIndexStatusRepository.findByProjectIdForUpdate(100L)) + .thenReturn(Optional.of(existing)); + + RagIndexStatus result = service.markIndexingCompleted( + testProject, "main", "older-commit", 25, 50, 91L); + + assertThat(result.getStatus()).isEqualTo(RagIndexingStatus.INDEXING); + assertThat(result.getActiveJobId()).isEqualTo(92L); + assertThat(result.getIndexedCommitHash()).isEqualTo("newer-commit"); + verify(ragIndexStatusRepository, never()).save(any()); + } + + @Test + void staleFailureCannotTerminalizeANewerJobOwner() { + RagIndexStatus existing = new RagIndexStatus(); + existing.setProject(testProject); + existing.setStatus(RagIndexingStatus.UPDATING); + existing.setActiveJobId(92L); + when(ragIndexStatusRepository.findByProjectIdForUpdate(100L)) + .thenReturn(Optional.of(existing)); + + RagIndexStatus result = service.markIncrementalUpdateFailed( + testProject, "older producer failed", 91L); + + assertThat(result.getStatus()).isEqualTo(RagIndexingStatus.UPDATING); + assertThat(result.getActiveJobId()).isEqualTo(92L); + assertThat(result.getErrorMessage()).isNull(); + verify(ragIndexStatusRepository, never()).save(any()); + } + // ── canStartIndexing ───────────────────────────────────────────────────── @Test diff --git a/java-ecosystem/libs/rag-engine/src/test/java/org/rostilos/codecrow/ragengine/service/RagOperationsServiceImplTest.java b/java-ecosystem/libs/rag-engine/src/test/java/org/rostilos/codecrow/ragengine/service/RagOperationsServiceImplTest.java index cbec2923..798e5026 100644 --- a/java-ecosystem/libs/rag-engine/src/test/java/org/rostilos/codecrow/ragengine/service/RagOperationsServiceImplTest.java +++ b/java-ecosystem/libs/rag-engine/src/test/java/org/rostilos/codecrow/ragengine/service/RagOperationsServiceImplTest.java @@ -129,7 +129,7 @@ void exactFirstUseBuildsTargetSnapshotWithoutPrimaryToDevelopDiff() throws Excep eq(testProject), any(), eq("my-workspace"), eq("my-repo"), eq("feature"), eq("develop-400"), eq(org.rostilos.codecrow.core.model.rag.RagBranchIndexKind.DURABLE), - any(), any(), eq(77L))) + any(), any(), eq(77L), eq("exact-feature-lock"), isNull())) .thenReturn(Map.of( "generation_manifest_sha256", "manifest-400", "document_count", 231, @@ -147,7 +147,7 @@ void exactFirstUseBuildsTargetSnapshotWithoutPrimaryToDevelopDiff() throws Excep eq(testProject), any(), eq("my-workspace"), eq("my-repo"), eq("feature"), eq("develop-400"), eq(org.rostilos.codecrow.core.model.rag.RagBranchIndexKind.DURABLE), - any(), any(), eq(77L)); + any(), any(), eq(77L), eq("exact-feature-lock"), isNull()); } @Test @@ -675,9 +675,7 @@ void testTriggerIncrementalUpdate_FullSuccessFlow() throws Exception { testProject, "feature", "commit1", "diff content", eventConsumer); assertThat(result).isTrue(); - verify(ragIndexTrackingService).markUpdatingStarted(testProject, "feature", "commit1"); - verify(ragIndexTrackingService).markUpdatingCompleted( - testProject, "feature", "commit1", 0, 1, null, false); + verifyNoInteractions(ragIndexTrackingService); verify(analysisLockService).releaseLock("lock-key"); verify(analysisJobService).completeJob(eq(mockJob), isNull()); verify(ragBranchIndexRepository).save(any(RagBranchIndex.class)); @@ -748,9 +746,9 @@ void testTriggerIncrementalUpdate_IncrementalUpdateThrows() throws Exception { service.triggerIncrementalUpdate(testProject, "feature", "c1", "diff", eventConsumer); assertThat(result).isFalse(); - // Should call markIncrementalUpdateFailed (keeps status INDEXED) NOT markIndexingFailed - verify(ragIndexTrackingService).markIncrementalUpdateFailed(eq(testProject), anyString()); - verify(ragIndexTrackingService, never()).markIndexingFailed(any(), anyString()); + // A retained branch has its own durable operation state and cannot + // overwrite the primary branch's project-level status. + verifyNoInteractions(ragIndexTrackingService); verify(analysisLockService).releaseLock("lock-key"); } @@ -804,7 +802,7 @@ void testTriggerIncrementalUpdate_UsesCompletedMainCheckpointAfterEarlierFailure eq("main"), eq("current-head"), eq(Set.of("src/Recovered.java")), eq(Set.of()), eq(Set.of())); verify(ragIndexTrackingService).markUpdatingCompleted( - testProject, "main", "current-head", 0, 0, null, true); + testProject, "main", "current-head", 0, 0, null, 0L); } @Test diff --git a/java-ecosystem/libs/rag-engine/src/test/java/org/rostilos/codecrow/ragengine/service/VcsRagIndexingServiceTest.java b/java-ecosystem/libs/rag-engine/src/test/java/org/rostilos/codecrow/ragengine/service/VcsRagIndexingServiceTest.java index 0c32536b..3a028f86 100644 --- a/java-ecosystem/libs/rag-engine/src/test/java/org/rostilos/codecrow/ragengine/service/VcsRagIndexingServiceTest.java +++ b/java-ecosystem/libs/rag-engine/src/test/java/org/rostilos/codecrow/ragengine/service/VcsRagIndexingServiceTest.java @@ -324,7 +324,8 @@ void shouldCompleteFullIndexing(boolean multiBranchEnabled) throws Exception { assertThat(result).containsEntry("status", "queued"); assertThat(result).containsEntry("branch", "main"); assertThat(result).containsEntry("jobId", "rag-job-123"); - verify(ragIndexTrackingService).markIndexingStarted(testProject, "main", "abc123"); + verify(ragIndexTrackingService).markIndexingStarted( + eq(testProject), eq("main"), eq("abc123"), anyLong()); verify(mockVcs).downloadRepositoryArchiveToFile( eq("my-workspace"), eq("my-repo"), @@ -375,7 +376,8 @@ void shouldHandleIndexingIOException() throws Exception { Map result = service.indexProjectFromVcs(createProjectDTO(100L), "main", messageConsumer); assertThat(result).containsEntry("status", "error"); - verify(ragIndexTrackingService).markIndexingFailed(eq(testProject), anyString()); + verify(ragIndexTrackingService).markIndexingFailed( + eq(testProject), anyString(), eq(0L)); verify(jobService).failJob(eq(mockJob), anyString()); verify(analysisLockService).releaseLock("lock-key"); } @@ -405,7 +407,8 @@ void pollingFailureDoesNotDeleteConsumerOwnedWorkspace() throws Exception { assertThat(consumerWorkspace).exists(); verify(analysisLockService).releaseLock("lock-key"); verify(analysisLockService).renewLock("lock-key", 30); - verify(ragIndexTrackingService).markIndexingFailed(testProject, "worker failed"); + verify(ragIndexTrackingService).markIndexingFailed( + testProject, "worker failed", null); } finally { Files.deleteIfExists(consumerWorkspace); } @@ -433,7 +436,7 @@ void workerStatusHeartbeatRefreshesObservableIndexStatus() { "codecrow:queue:rag", "queued-payload"); - verify(ragIndexTrackingService).markIndexingHeartbeat(testProject); + verify(ragIndexTrackingService).markIndexingHeartbeat(testProject, 0L); verify(jobService).logToJob( eq(job), eq(JobLogLevel.INFO), @@ -446,7 +449,8 @@ void workerStatusHeartbeatRefreshesObservableIndexStatus() { "main", "abc123", 12, - 34); + 34, + 0L); verify(analysisLockService, times(2)).renewLock("lock-key", 30); verify(analysisLockService).releaseLock("lock-key"); } diff --git a/java-ecosystem/libs/task-management/src/main/java/org/rostilos/codecrow/taskmanagement/jira/cloud/JiraCloudClient.java b/java-ecosystem/libs/task-management/src/main/java/org/rostilos/codecrow/taskmanagement/jira/cloud/JiraCloudClient.java index 84e07abe..2d56f32c 100644 --- a/java-ecosystem/libs/task-management/src/main/java/org/rostilos/codecrow/taskmanagement/jira/cloud/JiraCloudClient.java +++ b/java-ecosystem/libs/task-management/src/main/java/org/rostilos/codecrow/taskmanagement/jira/cloud/JiraCloudClient.java @@ -664,6 +664,23 @@ private ArrayNode buildInlineContent(String text) { while (pos < len) { char c = text.charAt(pos); + // ── Bare HTTP(S) URL ── + // Jira does not auto-link plain text inside an ADF document. Add + // the link mark explicitly, preserving fragments used by opaque + // public-share URLs. + if (text.startsWith("https://", pos) || text.startsWith("http://", pos)) { + int end = pos; + while (end < len && !Character.isWhitespace(text.charAt(end))) { + end++; + } + end = trimBareUrlEnd(text, pos, end); + String url = text.substring(pos, end); + flushPlain(nodes, plain); + addLinkTextNode(nodes, url, url); + pos = end; + continue; + } + // ── Backslash escape: \* \_ \` \\ ── if (c == '\\' && pos + 1 < len) { char next = text.charAt(pos + 1); @@ -731,6 +748,47 @@ private ArrayNode buildInlineContent(String text) { return nodes; } + private static int trimBareUrlEnd(String text, int start, int end) { + int trimmedEnd = end; + while (trimmedEnd > start) { + char trailing = text.charAt(trimmedEnd - 1); + if (".,;!?".indexOf(trailing) >= 0) { + trimmedEnd--; + continue; + } + char opening = switch (trailing) { + case ')' -> '('; + case ']' -> '['; + case '}' -> '{'; + default -> '\0'; + }; + if (opening == '\0' || !hasUnmatchedClosingDelimiter( + text, start, trimmedEnd, opening, trailing)) { + break; + } + trimmedEnd--; + } + return trimmedEnd; + } + + private static boolean hasUnmatchedClosingDelimiter( + String text, + int start, + int end, + char opening, + char closing) { + int balance = 0; + for (int index = start; index < end; index++) { + char current = text.charAt(index); + if (current == opening) { + balance++; + } else if (current == closing) { + balance--; + } + } + return balance < 0; + } + /** Flush accumulated plain text into a text node, then clear the buffer. */ private void flushPlain(ArrayNode nodes, StringBuilder buffer) { if (buffer.isEmpty()) { @@ -756,6 +814,23 @@ private void addTextNode(ArrayNode nodes, String text, List markTypes) { nodes.add(textNode); } + private void addLinkTextNode(ArrayNode nodes, String text, String href) { + ObjectNode textNode = objectMapper.createObjectNode(); + textNode.put("type", "text"); + textNode.put("text", text); + + ObjectNode link = objectMapper.createObjectNode(); + link.put("type", "link"); + ObjectNode attributes = objectMapper.createObjectNode(); + attributes.put("href", href); + link.set("attrs", attributes); + + ArrayNode marks = objectMapper.createArrayNode(); + marks.add(link); + textNode.set("marks", marks); + nodes.add(textNode); + } + // ─── Response helpers ──────────────────────────────────────────── private void ensureSuccess(Response response, String operation) throws IOException { diff --git a/java-ecosystem/libs/task-management/src/test/java/org/rostilos/codecrow/taskmanagement/jira/cloud/JiraCloudClientTest.java b/java-ecosystem/libs/task-management/src/test/java/org/rostilos/codecrow/taskmanagement/jira/cloud/JiraCloudClientTest.java index 770cae1f..d9cb6d1a 100644 --- a/java-ecosystem/libs/task-management/src/test/java/org/rostilos/codecrow/taskmanagement/jira/cloud/JiraCloudClientTest.java +++ b/java-ecosystem/libs/task-management/src/test/java/org/rostilos/codecrow/taskmanagement/jira/cloud/JiraCloudClientTest.java @@ -145,6 +145,60 @@ void postCommentMovesCodeCrowMarkersOutOfAdfContent() throws Exception { "codecrow-ask-response"); } + @Test + @DisplayName("renders a bare public-share URL as a clickable Jira link") + void postCommentAddsAdfLinkMarkToBarePublicShareUrl() throws Exception { + server.enqueue(commentResponse(201)); + String shareUrl = "https://codecrow.cloud/share#token=ccs_opaque-token&tab=environment"; + + client.postComment("PROJ-123", """ + ### 3. Test Scenarios + + %s + """.formatted(shareUrl)); + + JsonNode payload = mapper.readTree(server.takeRequest().getBody().readUtf8()); + JsonNode linkedText = payload.path("body").path("content").get(1) + .path("content").get(0); + assertThat(linkedText.path("text").asText()).isEqualTo(shareUrl); + assertThat(linkedText.path("marks").get(0).path("type").asText()) + .isEqualTo("link"); + assertThat(linkedText.path("marks").get(0).path("attrs").path("href").asText()) + .isEqualTo(shareUrl); + } + + @Test + @DisplayName("keeps sentence delimiters outside bare URL links") + void postCommentExcludesUnmatchedClosingDelimitersFromBareUrls() throws Exception { + server.enqueue(commentResponse(201)); + String shareUrl = "https://codecrow.cloud/share#token=ccs_opaque-token&tab=test-cases"; + + client.postComment("PROJ-123", "Open (" + shareUrl + ") or [" + shareUrl + "]."); + + JsonNode paragraph = mapper.readTree(server.takeRequest().getBody().readUtf8()) + .path("body").path("content").get(0); + assertThat(paragraph.path("content").findValuesAsText("text")) + .containsExactly("Open (", shareUrl, ") or [", shareUrl, "]."); + assertThat(paragraph.path("content").findValues("attrs")) + .extracting(node -> node.path("href").asText()) + .containsExactly(shareUrl, shareUrl); + } + + @Test + @DisplayName("preserves balanced delimiters that belong to a bare URL") + void postCommentKeepsBalancedUrlDelimiters() throws Exception { + server.enqueue(commentResponse(201)); + String shareUrl = "https://example.test/docs/function_(value)"; + + client.postComment("PROJ-123", shareUrl); + + JsonNode linkedText = mapper.readTree(server.takeRequest().getBody().readUtf8()) + .path("body").path("content").get(0).path("content").get(0); + assertThat(linkedText.path("text").asText()).isEqualTo(shareUrl); + assertThat(linkedText.path("marks").get(0).path("attrs").path("href").asText()) + .isEqualTo(shareUrl); + } + @Test @DisplayName("finds a Jira comment by non-rendered CodeCrow marker property") void findCommentByMarkerUsesCommentProperties() throws Exception { diff --git a/java-ecosystem/pom.xml b/java-ecosystem/pom.xml index 51123c1f..8d382772 100644 --- a/java-ecosystem/pom.xml +++ b/java-ecosystem/pom.xml @@ -110,6 +110,12 @@ 1.0 + + org.rostilos.codecrow + codecrow-public-share + 1.0 + + org.rostilos.codecrow codecrow-commit-graph @@ -588,6 +594,7 @@ libs/vcs-client libs/core + libs/public-share libs/scm-evidence libs/commit-graph libs/file-content diff --git a/java-ecosystem/services/pipeline-agent/pom.xml b/java-ecosystem/services/pipeline-agent/pom.xml index 88ec41f5..475e21bc 100644 --- a/java-ecosystem/services/pipeline-agent/pom.xml +++ b/java-ecosystem/services/pipeline-agent/pom.xml @@ -60,6 +60,11 @@ codecrow-core + + org.rostilos.codecrow + codecrow-public-share + + org.rostilos.codecrow codecrow-commit-graph diff --git a/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/config/AsyncConfig.java b/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/config/AsyncConfig.java index 5f6b4228..2fa44337 100644 --- a/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/config/AsyncConfig.java +++ b/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/config/AsyncConfig.java @@ -6,6 +6,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; import java.util.concurrent.Executor; @@ -22,6 +23,23 @@ public class AsyncConfig { private static final Logger log = LoggerFactory.getLogger(AsyncConfig.class); + /** + * Keep liveness/recovery schedules independent from optional maintenance. + * A slow provider or Qdrant enrichment task must not prevent durable job + * recovery, heartbeat checks, or queue reconciliation from running. + */ + @Bean(name = "taskScheduler") + public ThreadPoolTaskScheduler taskScheduler( + @Value("${spring.task.scheduling.pool.size:4}") int poolSize) { + ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); + scheduler.setPoolSize(poolSize); + scheduler.setThreadNamePrefix("scheduling-"); + scheduler.setWaitForTasksToCompleteOnShutdown(true); + scheduler.setAwaitTerminationSeconds(30); + log.info("Scheduled task executor initialized with pool={}", poolSize); + return scheduler; + } + /** * Default executor for general async tasks. */ diff --git a/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/generic/processor/command/QaDocCommandProcessor.java b/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/generic/processor/command/QaDocCommandProcessor.java index b2f0814a..e6ca5580 100644 --- a/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/generic/processor/command/QaDocCommandProcessor.java +++ b/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/generic/processor/command/QaDocCommandProcessor.java @@ -11,6 +11,7 @@ import org.rostilos.codecrow.core.model.project.config.QaAutoDocConfig; import org.rostilos.codecrow.core.model.project.config.TaskManagementConfig; import org.rostilos.codecrow.core.model.qadoc.QaDocState; +import org.rostilos.codecrow.core.model.qadoc.QaDocDocument; import org.rostilos.codecrow.core.model.taskmanagement.TaskManagementConnection; import org.rostilos.codecrow.core.model.vcs.VcsConnection; import org.rostilos.codecrow.core.model.vcs.VcsRepoInfo; @@ -24,6 +25,8 @@ import org.rostilos.codecrow.pipelineagent.qadoc.QaAutoDocListener; import org.rostilos.codecrow.pipelineagent.qadoc.QaDocGenerationContext; import org.rostilos.codecrow.pipelineagent.qadoc.QaDocGenerationService; +import org.rostilos.codecrow.pipelineagent.qadoc.QaDocHandoffRetryPolicy; +import org.rostilos.codecrow.pipelineagent.qadoc.QaDocPublicPreviewService; import org.rostilos.codecrow.taskmanagement.ETaskManagementPlatform; import org.rostilos.codecrow.taskmanagement.TaskManagementClient; import org.rostilos.codecrow.taskmanagement.TaskManagementClientFactory; @@ -48,8 +51,8 @@ /** * Processor for the {@code /codecrow qa-doc} command. *

- * Generates QA testing documentation for the current PR and posts it as a comment - * on the linked Jira task. Reuses the same inference-orchestrator endpoint and + * Generates QA testing documentation for the current PR and posts its public + * QA-document preview links on the linked Jira task. Reuses the same inference-orchestrator endpoint and * task management client infrastructure as the automatic {@link QaAutoDocListener}. *

* Usage: @@ -70,6 +73,7 @@ public class QaDocCommandProcessor implements CommentCommandProcessor { private final VcsClientProvider vcsClientProvider; private final QaDocStateRepository qaDocStateRepository; private final QaDocDocumentService qaDocDocumentService; + private final QaDocPublicPreviewService qaDocPublicPreviewService; private final PrFileEnrichmentService enrichmentService; private final VcsConnectionCredentialsExtractor credentialsExtractor; @@ -81,6 +85,7 @@ public QaDocCommandProcessor( VcsClientProvider vcsClientProvider, QaDocStateRepository qaDocStateRepository, QaDocDocumentService qaDocDocumentService, + QaDocPublicPreviewService qaDocPublicPreviewService, PrFileEnrichmentService enrichmentService, TokenEncryptionService tokenEncryptionService ) { @@ -91,6 +96,7 @@ public QaDocCommandProcessor( this.vcsClientProvider = vcsClientProvider; this.qaDocStateRepository = qaDocStateRepository; this.qaDocDocumentService = qaDocDocumentService; + this.qaDocPublicPreviewService = qaDocPublicPreviewService; this.enrichmentService = enrichmentService; this.credentialsExtractor = new VcsConnectionCredentialsExtractor(tokenEncryptionService); } @@ -292,6 +298,18 @@ public WebhookResult process( ? qaDocStateRepository.findByProjectIdAndTaskId(project.getId(), taskId).orElse(null) : null; boolean isSamePrRerun = (state != null && prNumber != null && state.isPrDocumented(prNumber)); + Optional pendingHandoffDocument = Optional.empty(); + if (prNumber != null) { + try { + pendingHandoffDocument = qaDocDocumentService + .findLatestDocument(project.getId(), prNumber) + .filter(document -> QaDocHandoffRetryPolicy.shouldReuse( + document, state, commitHash, taskId)); + } catch (Exception e) { + log.warn("qa-doc command: failed to inspect pending handoff for project {} PR #{}: {}", + project.getId(), prNumber, e.getMessage()); + } + } Optional existingComment = Optional.empty(); try { @@ -318,7 +336,9 @@ public WebhookResult process( taskId, e.getMessage()); } - if (previousDocumentation == null && existingComment.isPresent()) { + if (previousDocumentation == null + && existingComment.isPresent() + && !QaAutoDocListener.isPublicPreviewOnlyComment(existingComment.get().body())) { previousDocumentation = existingComment.get().body(); log.info("qa-doc command: using existing task comment as previous documentation for task {}", taskId); @@ -326,7 +346,8 @@ public WebhookResult process( // 6a. Compute delta diff for same-PR re-runs String deltaDiff = null; - if (isSamePrRerun && state.getLastCommitHash() != null + if (pendingHandoffDocument.isEmpty() + && isSamePrRerun && state.getLastCommitHash() != null && commitHash != null && vcsClient != null) { DiffContentFilter contentFilter = new DiffContentFilter(); final VcsClient clientForDiff = vcsClient; @@ -361,8 +382,15 @@ public WebhookResult process( .bearerToken(bearerToken) .build(); - String qaDocument = qaDocGenerationService.generateQaDocumentation( - project, prNumber, issuesFound, filesAnalyzed, prMetadata, ctx); + String qaDocument; + if (pendingHandoffDocument.isPresent()) { + qaDocument = pendingHandoffDocument.get().getMarkdownContent(); + log.info("qa-doc command: retrying pending task handoff without regenerating project {} PR #{}", + project.getId(), prNumber); + } else { + qaDocument = qaDocGenerationService.generateQaDocumentation( + project, prNumber, issuesFound, filesAnalyzed, prMetadata, ctx); + } if (qaDocument == null || qaDocument.isBlank()) { return WebhookResult.ignored( @@ -370,18 +398,27 @@ public WebhookResult process( } Long analysisId = (analysis != null) ? analysis.getId() : null; - if (prNumber != null) { - upsertQaDocDocument(project, prNumber, taskId, commitHash, analysisId, qaDocument); + Optional persistedDocument = pendingHandoffDocument; + if (persistedDocument.isEmpty() && prNumber != null) { + persistedDocument = upsertQaDocDocument( + project, prNumber, taskId, commitHash, analysisId, qaDocument); } + if (persistedDocument.isEmpty()) { + return WebhookResult.error( + "QA documentation was generated but its secure public preview could not be created."); + } + + String previewUrl = qaDocPublicPreviewService.createPreviewUrl(persistedDocument.get()); eventConsumer.accept(Map.of( "type", "status", "state", "posting_comment", - "message", "Posting documentation to " + taskId + "..." + "message", "Posting QA-document preview links to " + taskId + "..." )); - // 8. Post or update comment on Jira task - String commentBody = QaAutoDocListener.COMMENT_MARKER + "\n\n" + qaDocument; + // 8. Keep the QA guide in Jira, replacing only its large test-case + // section with the public preview URL. + String commentBody = qaDocPublicPreviewService.buildTaskComment(qaDocument, previewUrl); TaskCommentVisibility visibility = toTaskCommentVisibility(qaConfig.commentVisibility()); String action; @@ -450,16 +487,18 @@ public WebhookResult process( } } - private void upsertQaDocDocument(Project project, Long prNumber, String taskId, - String commitHash, Long analysisId, String qaDocument) { + private Optional upsertQaDocDocument(Project project, Long prNumber, String taskId, + String commitHash, Long analysisId, String qaDocument) { try { - qaDocDocumentService.upsertLatestDocument( + QaDocDocument document = qaDocDocumentService.upsertLatestDocument( project, prNumber, taskId, analysisId, commitHash, qaDocument); log.debug("qa-doc command: persisted latest document for project {} PR #{}", project.getId(), prNumber); + return Optional.of(document); } catch (Exception e) { log.warn("qa-doc command: failed to persist latest document for project {} PR #{}: {}", project.getId(), prNumber, e.getMessage()); + return Optional.empty(); } } diff --git a/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/qadoc/QaAutoDocListener.java b/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/qadoc/QaAutoDocListener.java index 6357cf37..fcbf0508 100644 --- a/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/qadoc/QaAutoDocListener.java +++ b/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/qadoc/QaAutoDocListener.java @@ -11,6 +11,7 @@ import org.rostilos.codecrow.core.model.project.config.QaAutoDocConfig; import org.rostilos.codecrow.core.model.project.config.TaskManagementConfig; import org.rostilos.codecrow.core.model.qadoc.QaDocState; +import org.rostilos.codecrow.core.model.qadoc.QaDocDocument; import org.rostilos.codecrow.core.model.taskmanagement.TaskManagementConnection; import org.rostilos.codecrow.core.model.vcs.VcsConnection; import org.rostilos.codecrow.core.model.vcs.VcsRepoInfo; @@ -73,6 +74,7 @@ public class QaAutoDocListener { private final VcsClientProvider vcsClientProvider; private final QaDocStateRepository qaDocStateRepository; private final QaDocDocumentService qaDocDocumentService; + private final QaDocPublicPreviewService qaDocPublicPreviewService; private final PrFileEnrichmentService enrichmentService; private final VcsConnectionCredentialsExtractor credentialsExtractor; @@ -84,6 +86,7 @@ public QaAutoDocListener(ProjectRepository projectRepository, VcsClientProvider vcsClientProvider, QaDocStateRepository qaDocStateRepository, QaDocDocumentService qaDocDocumentService, + QaDocPublicPreviewService qaDocPublicPreviewService, PrFileEnrichmentService enrichmentService, TokenEncryptionService tokenEncryptionService) { this.projectRepository = projectRepository; @@ -94,6 +97,7 @@ public QaAutoDocListener(ProjectRepository projectRepository, this.vcsClientProvider = vcsClientProvider; this.qaDocStateRepository = qaDocStateRepository; this.qaDocDocumentService = qaDocDocumentService; + this.qaDocPublicPreviewService = qaDocPublicPreviewService; this.enrichmentService = enrichmentService; this.credentialsExtractor = new VcsConnectionCredentialsExtractor(tokenEncryptionService); } @@ -200,6 +204,17 @@ private void processQaAutoDoc(AnalysisCompletedEvent event) throws Exception { String sourceBranch = (metrics != null) ? (String) metrics.get("sourceBranch") : null; String targetBranch = (metrics != null) ? (String) metrics.get("targetBranch") : null; + Optional pendingHandoffDocument = Optional.empty(); + try { + pendingHandoffDocument = qaDocDocumentService + .findLatestDocument(project.getId(), prNumber) + .filter(document -> QaDocHandoffRetryPolicy.shouldReuse( + document, state, currentCommitHash, taskId)); + } catch (Exception e) { + log.warn("QA auto-doc: failed to inspect pending handoff for project {} PR #{}: {}", + project.getId(), prNumber, e.getMessage()); + } + // 5a. Fetch full PR diff String diff = null; VcsClient vcsClient = null; @@ -229,7 +244,8 @@ private void processQaAutoDoc(AnalysisCompletedEvent event) throws Exception { // 5c. Compute delta diff for same-PR re-runs (incremental update) String deltaDiff = null; - if (isSamePrRerun && state.getLastCommitHash() != null + if (pendingHandoffDocument.isEmpty() + && isSamePrRerun && state.getLastCommitHash() != null && currentCommitHash != null && vcsClient != null) { DiffContentFilter contentFilter = new DiffContentFilter(); final VcsClient client = vcsClient; @@ -327,7 +343,9 @@ private void processQaAutoDoc(AnalysisCompletedEvent event) throws Exception { taskId, e.getMessage()); } - if (previousDocumentation == null && existingComment.isPresent()) { + if (previousDocumentation == null + && existingComment.isPresent() + && !isPublicPreviewOnlyComment(existingComment.get().body())) { previousDocumentation = existingComment.get().body(); log.info("QA auto-doc: using existing task comment as previous documentation for task {}", taskId); @@ -354,8 +372,14 @@ private void processQaAutoDoc(AnalysisCompletedEvent event) throws Exception { .bearerToken(bearerToken) .build(); - String qaDocument = qaDocGenerationService.generateQaDocumentation( - project, event, ctx); + String qaDocument; + if (pendingHandoffDocument.isPresent()) { + qaDocument = pendingHandoffDocument.get().getMarkdownContent(); + log.info("QA auto-doc: retrying pending task handoff without regenerating project {} PR #{}", + project.getId(), prNumber); + } else { + qaDocument = qaDocGenerationService.generateQaDocumentation(project, event, ctx); + } if (qaDocument == null || qaDocument.isBlank()) { log.info("QA auto-doc: LLM determined no documentation needed for task {}", taskId); @@ -369,13 +393,28 @@ private void processQaAutoDoc(AnalysisCompletedEvent event) throws Exception { return; } - // 9. Update server-side state (secure, tamper-proof) + // 9. Persist the full document before disclosing a preview link. Long analysisId = (analysis != null) ? analysis.getId() : null; - upsertQaDocState(project, taskId, currentCommitHash, analysisId, prNumber, state); - upsertQaDocDocument(project, prNumber, taskId, currentCommitHash, analysisId, qaDocument); + Optional persistedDocument = pendingHandoffDocument.isPresent() + ? pendingHandoffDocument + : upsertQaDocDocument( + project, prNumber, taskId, currentCommitHash, analysisId, qaDocument); + if (persistedDocument.isEmpty()) { + log.warn("QA auto-doc: not updating task {} because a secure public preview could not be created", taskId); + return; + } - // 10. Post or update Jira comment - String commentBody = COMMENT_MARKER + "\n\n" + qaDocument; + // 10. Keep the QA guide in Jira, replacing only its large test-case + // section with the public preview URL. + String commentBody; + try { + String previewUrl = qaDocPublicPreviewService.createPreviewUrl(persistedDocument.get()); + commentBody = qaDocPublicPreviewService.buildTaskComment(qaDocument, previewUrl); + } catch (Exception e) { + log.warn("QA auto-doc: failed to create public QA-document preview for task {}: {}", + taskId, e.getMessage()); + return; + } TaskCommentVisibility visibility = toTaskCommentVisibility(qaConfig.commentVisibility()); try { @@ -399,7 +438,12 @@ private void processQaAutoDoc(AnalysisCompletedEvent event) throws Exception { String errorMessage = describeTaskManagementFailure(e); log.error("QA auto-doc: failed to post/update comment on task {}: {}", taskId, errorMessage, e); + return; } + + // Advance generation state only after the durable document, preview, + // and task handoff all succeeded, so a failed handoff remains retryable. + upsertQaDocState(project, taskId, currentCommitHash, analysisId, prNumber, state); } // ── Enrichment helpers ────────────────────────────────────────── @@ -476,18 +520,19 @@ protected void upsertQaDocState(Project project, String taskId, /** * Upsert the latest rendered QA doc markdown for the PR. */ - protected void upsertQaDocDocument(Project project, Long prNumber, String taskId, - String commitHash, Long analysisId, - String qaDocument) { + protected Optional upsertQaDocDocument(Project project, Long prNumber, String taskId, + String commitHash, Long analysisId, + String qaDocument) { try { - qaDocDocumentService.upsertLatestDocument( + QaDocDocument document = qaDocDocumentService.upsertLatestDocument( project, prNumber, taskId, analysisId, commitHash, qaDocument); log.debug("QA auto-doc: persisted latest document for project {} PR #{}", project.getId(), prNumber); + return Optional.of(document); } catch (Exception e) { - // Non-critical: Jira posting should still proceed. log.warn("QA auto-doc: failed to persist latest document for project {} PR #{}: {}", project.getId(), prNumber, e.getMessage()); + return Optional.empty(); } } @@ -554,6 +599,18 @@ private static String describeTaskManagementFailure(Exception e) { return e.getMessage(); } + public static boolean isPublicPreviewOnlyComment(String body) { + if (body == null || (!body.contains("/share#token=ccs_") + && !body.contains("/share?token=ccs_"))) { + return false; + } + String withoutPreviewLink = body + .replaceAll("https?://\\S+/share(?:#|\\?)token=ccs_[A-Za-z0-9_-]+", "") + .replace("View QA test cases in CodeCrow", "") + .replaceAll("[\\[\\]()\\s]", ""); + return withoutPreviewLink.isBlank(); + } + private static String truncateProviderMessage(String providerMessage) { if (providerMessage == null || providerMessage.isBlank()) { return null; diff --git a/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/qadoc/QaDocGenerationContext.java b/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/qadoc/QaDocGenerationContext.java index 3a768f71..fcf7d21c 100644 --- a/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/qadoc/QaDocGenerationContext.java +++ b/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/qadoc/QaDocGenerationContext.java @@ -34,7 +34,7 @@ public record QaDocGenerationContext( List changedFilePaths, // ── State / mode flags ─────────────────────────────────── - /** Existing QA doc comment body from an earlier PR on the same task (for merging). May be null. */ + /** Existing stored QA document from an earlier PR on the same task (for merging). May be null. */ String previousDocumentation, /** True when the current PR was already documented and this is a re-analysis (e.g., new commits pushed). */ boolean isSamePrRerun, diff --git a/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/qadoc/QaDocGenerationService.java b/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/qadoc/QaDocGenerationService.java index 97b6a3fc..d88b778c 100644 --- a/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/qadoc/QaDocGenerationService.java +++ b/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/qadoc/QaDocGenerationService.java @@ -38,7 +38,7 @@ *

    *
  • Assembling the request payload (analysis summary, task context, template config)
  • *
  • Making the HTTP call with retry logic
  • - *
  • Parsing the response into a ready-to-post comment body
  • + *
  • Parsing the response into the full document stored by CodeCrow
  • *
*/ @Service diff --git a/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/qadoc/QaDocHandoffRetryPolicy.java b/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/qadoc/QaDocHandoffRetryPolicy.java new file mode 100644 index 00000000..8625cc22 --- /dev/null +++ b/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/qadoc/QaDocHandoffRetryPolicy.java @@ -0,0 +1,52 @@ +package org.rostilos.codecrow.pipelineagent.qadoc; + +import org.rostilos.codecrow.core.model.qadoc.QaDocDocument; +import org.rostilos.codecrow.core.model.qadoc.QaDocState; + +import java.time.OffsetDateTime; +import java.util.Locale; +import java.util.Objects; + +/** Identifies a durable QA document whose external task handoff is still pending. */ +public final class QaDocHandoffRetryPolicy { + + private QaDocHandoffRetryPolicy() { + } + + public static boolean shouldReuse( + QaDocDocument document, + QaDocState state, + String currentCommitHash, + String expectedTaskId) { + if (document == null + || currentCommitHash == null + || currentCommitHash.isBlank() + || expectedTaskId == null + || expectedTaskId.isBlank() + || document.getMarkdownContent() == null + || document.getMarkdownContent().isBlank() + || !Objects.equals(normalize(document.getCommitHash()), normalize(currentCommitHash)) + || !Objects.equals( + normalizeTaskId(document.getTaskId()), + normalizeTaskId(expectedTaskId))) { + return false; + } + + OffsetDateTime documentGeneratedAt = document.getGeneratedAt(); + if (documentGeneratedAt == null) { + return false; + } + return state == null + || state.getLastGeneratedAt() == null + || documentGeneratedAt.isAfter(state.getLastGeneratedAt()); + } + + private static String normalize(String value) { + return value == null ? null : value.trim(); + } + + private static String normalizeTaskId(String value) { + String normalized = normalize(value); + return normalized == null ? null : normalized.toUpperCase(Locale.ROOT); + } +} diff --git a/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/qadoc/QaDocPublicPreviewService.java b/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/qadoc/QaDocPublicPreviewService.java new file mode 100644 index 00000000..1943cab4 --- /dev/null +++ b/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/qadoc/QaDocPublicPreviewService.java @@ -0,0 +1,56 @@ +package org.rostilos.codecrow.pipelineagent.qadoc; + +import org.rostilos.codecrow.core.model.qadoc.QaDocDocument; +import org.rostilos.codecrow.core.service.SiteSettingsProvider; +import org.rostilos.codecrow.core.service.qadoc.QaDocContentParser; +import org.rostilos.codecrow.core.service.qadoc.QaDocPublicShareResource; +import org.rostilos.codecrow.publicshare.api.IssuedPublicShare; +import org.rostilos.codecrow.publicshare.service.PublicShareLinkService; +import org.springframework.stereotype.Service; + +/** QA-doc adapter over the purpose-neutral public-share credential service. */ +@Service +public class QaDocPublicPreviewService { + + private final PublicShareLinkService publicShareLinkService; + private final SiteSettingsProvider siteSettingsProvider; + + public QaDocPublicPreviewService(PublicShareLinkService publicShareLinkService, + SiteSettingsProvider siteSettingsProvider) { + this.publicShareLinkService = publicShareLinkService; + this.siteSettingsProvider = siteSettingsProvider; + } + + public String createPreviewUrl(QaDocDocument document) { + if (document == null || document.getId() == null) { + throw new IllegalArgumentException("A persisted QA document is required for public preview."); + } + if (QaDocContentParser.parseMarkedTestCases(document.getMarkdownContent()).isEmpty()) { + throw new IllegalArgumentException( + "A marked QA test-case section is required for public preview."); + } + IssuedPublicShare share = publicShareLinkService.issue( + QaDocPublicShareResource.DOCUMENT, + document.getId().toString() + ); + return share.toFrontendUrl(siteSettingsProvider.getBaseUrlSettings().frontendUrl()); + } + + public String buildTaskComment(String qaDocument, String previewUrl) { + if (previewUrl == null || previewUrl.isBlank()) { + throw new IllegalArgumentException("A public preview URL is required."); + } + String normalizedPreviewUrl = previewUrl.trim(); + return QaAutoDocListener.COMMENT_MARKER + + "\n\n" + + QaDocContentParser.replaceShareableSections( + qaDocument, + withTab(normalizedPreviewUrl, "test-cases"), + withTab(normalizedPreviewUrl, "environment") + ); + } + + private static String withTab(String previewUrl, String tab) { + return previewUrl + (previewUrl.contains("#") ? "&" : "#") + "tab=" + tab; + } +} diff --git a/java-ecosystem/services/pipeline-agent/src/test/java/org/rostilos/codecrow/pipelineagent/config/AsyncConfigTest.java b/java-ecosystem/services/pipeline-agent/src/test/java/org/rostilos/codecrow/pipelineagent/config/AsyncConfigTest.java index 63942429..41f90812 100644 --- a/java-ecosystem/services/pipeline-agent/src/test/java/org/rostilos/codecrow/pipelineagent/config/AsyncConfigTest.java +++ b/java-ecosystem/services/pipeline-agent/src/test/java/org/rostilos/codecrow/pipelineagent/config/AsyncConfigTest.java @@ -2,7 +2,9 @@ import org.junit.jupiter.api.Test; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; +import java.time.Instant; import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executor; import java.util.concurrent.TimeUnit; @@ -11,6 +13,33 @@ class AsyncConfigTest { + @Test + void scheduledRecoveryIsNotStarvedBySlowOptionalMaintenance() throws Exception { + ThreadPoolTaskScheduler scheduler = new AsyncConfig().taskScheduler(2); + scheduler.initialize(); + CountDownLatch started = new CountDownLatch(2); + CountDownLatch release = new CountDownLatch(1); + + try { + Runnable blockingTask = () -> { + started.countDown(); + try { + release.await(); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + } + }; + scheduler.schedule(blockingTask, Instant.now()); + scheduler.schedule(blockingTask, Instant.now()); + + assertThat(started.await(2, TimeUnit.SECONDS)).isTrue(); + assertThat(scheduler.getPoolSize()).isEqualTo(2); + } finally { + release.countDown(); + scheduler.shutdown(); + } + } + @Test void webhookExecutorRunsFiveAcceptedReviewsInParallel() throws Exception { Executor configured = new AsyncConfig().webhookExecutor(2, 5); diff --git a/java-ecosystem/services/pipeline-agent/src/test/java/org/rostilos/codecrow/pipelineagent/generic/processor/command/QaDocCommandProcessorTest.java b/java-ecosystem/services/pipeline-agent/src/test/java/org/rostilos/codecrow/pipelineagent/generic/processor/command/QaDocCommandProcessorTest.java index 5e1e97e5..d7ed377e 100644 --- a/java-ecosystem/services/pipeline-agent/src/test/java/org/rostilos/codecrow/pipelineagent/generic/processor/command/QaDocCommandProcessorTest.java +++ b/java-ecosystem/services/pipeline-agent/src/test/java/org/rostilos/codecrow/pipelineagent/generic/processor/command/QaDocCommandProcessorTest.java @@ -18,6 +18,8 @@ import org.rostilos.codecrow.core.model.project.config.TaskManagementConfig; import org.rostilos.codecrow.core.model.taskmanagement.ETaskManagementProvider; import org.rostilos.codecrow.core.model.taskmanagement.TaskManagementConnection; +import org.rostilos.codecrow.core.model.qadoc.QaDocDocument; +import org.rostilos.codecrow.core.model.qadoc.QaDocState; import org.rostilos.codecrow.core.model.vcs.EVcsProvider; import org.rostilos.codecrow.core.persistence.repository.taskmanagement.TaskManagementConnectionRepository; import org.rostilos.codecrow.core.service.CodeAnalysisService; @@ -27,6 +29,7 @@ import org.rostilos.codecrow.pipelineagent.generic.webhookhandler.WebhookHandler.WebhookResult; import org.rostilos.codecrow.pipelineagent.qadoc.QaDocGenerationContext; import org.rostilos.codecrow.pipelineagent.qadoc.QaDocGenerationService; +import org.rostilos.codecrow.pipelineagent.qadoc.QaDocPublicPreviewService; import org.rostilos.codecrow.core.persistence.repository.qadoc.QaDocStateRepository; import org.rostilos.codecrow.analysisengine.service.pr.PrFileEnrichmentService; import org.rostilos.codecrow.security.oauth.TokenEncryptionService; @@ -60,6 +63,7 @@ class QaDocCommandProcessorTest { @Mock private VcsClientProvider vcsClientProvider; @Mock private QaDocStateRepository qaDocStateRepository; @Mock private QaDocDocumentService qaDocDocumentService; + @Mock private QaDocPublicPreviewService qaDocPublicPreviewService; @Mock private PrFileEnrichmentService enrichmentService; @Mock private TokenEncryptionService tokenEncryptionService; @@ -75,6 +79,22 @@ class QaDocCommandProcessorTest { private static final Long CONNECTION_ID = 100L; private static final String TASK_ID = "PROJ-123"; private static final String PR_ID = "7"; + private static final String GENERATED_QA_DOC = """ + ### 1. Change Summary + Login validation changed. + + ### 2. Scope + Web login flow. + + + ### 3. Test Scenarios + **Reject an invalid password** (HIGH) + - **Expected Result:** Test steps here + + + ### 4. Edge Cases + Verify an empty password. + """; @BeforeEach void setUp() { @@ -86,6 +106,7 @@ void setUp() { vcsClientProvider, qaDocStateRepository, qaDocDocumentService, + qaDocPublicPreviewService, enrichmentService, tokenEncryptionService ); @@ -94,6 +115,21 @@ void setUp() { ReflectionTestUtils.setField(project, "id", PROJECT_ID); ReflectionTestUtils.setField(project, "name", "Test Project"); + QaDocDocument persistedDocument = new QaDocDocument(project, 7L); + ReflectionTestUtils.setField(persistedDocument, "id", 701L); + lenient().when(qaDocDocumentService.upsertLatestDocument( + any(), anyLong(), anyString(), nullable(Long.class), nullable(String.class), anyString() + )).thenReturn(persistedDocument); + lenient().when(qaDocPublicPreviewService.createPreviewUrl(any())) + .thenReturn("https://app.codecrow.example/share#token=ccs_test-token"); + lenient().when(qaDocPublicPreviewService.buildTaskComment(anyString(), anyString())) + .thenReturn("\n\n" + + "### 1. Change Summary\nLogin validation changed.\n\n" + + "### 2. Scope\nWeb login flow.\n\n" + + "### 3. Test Scenarios\n\n" + + "https://app.codecrow.example/share#token=ccs_test-token\n\n" + + "### 4. Edge Cases\nVerify an empty password."); + capturedEvents = new ArrayList<>(); eventConsumer = capturedEvents::add; } @@ -203,10 +239,10 @@ private void setupHappyPath() throws IOException { when(codeAnalysisService.getPreviousVersionCodeAnalysis(PROJECT_ID, 7L)) .thenReturn(Optional.of(analysis)); - when(qaDocGenerationService.generateQaDocumentation( + lenient().when(qaDocGenerationService.generateQaDocumentation( any(Project.class), anyLong(), anyInt(), anyInt(), anyMap(), any(QaDocGenerationContext.class) - )).thenReturn("## QA Documentation\n\nTest steps here..."); + )).thenReturn(GENERATED_QA_DOC); } // ════════════════════════════════════════════════════════════════ @@ -493,12 +529,25 @@ void shouldGenerateAndPostNewComment() throws IOException { anyMap(), any(QaDocGenerationContext.class) ); - // Verify comment was posted (not updated) - verify(taskManagementClient).postComment(eq(TASK_ID), contains("codecrow-qa-autodoc")); + // Every section remains in Jira except the broad test-case body. + ArgumentCaptor commentCaptor = ArgumentCaptor.forClass(String.class); + verify(taskManagementClient).postComment(eq(TASK_ID), commentCaptor.capture()); + assertThat(commentCaptor.getValue()) + .contains( + "codecrow-qa-autodoc", + "### 1. Change Summary", + "Login validation changed", + "### 2. Scope", + "### 3. Test Scenarios", + "/share#token=ccs_", + "### 4. Edge Cases", + "Verify an empty password") + .doesNotContain("Reject an invalid password") + .doesNotContain("Test steps here"); verify(taskManagementClient, never()).updateComment(anyString(), anyString(), anyString()); verify(qaDocDocumentService).upsertLatestDocument( eq(project), eq(7L), eq(TASK_ID), isNull(), eq("abc123"), - eq("## QA Documentation\n\nTest steps here...") + eq(GENERATED_QA_DOC) ); } @@ -549,6 +598,59 @@ void shouldUpdateExistingComment() throws IOException { verify(taskManagementClient, never()).postComment(anyString(), anyString()); } + @Test + @DisplayName("should reuse a current document when only the Jira handoff is pending") + void shouldRetryPendingHandoffWithoutRegenerating() throws IOException { + setupHappyPath(); + QaDocDocument pendingDocument = new QaDocDocument(project, 7L); + ReflectionTestUtils.setField(pendingDocument, "id", 702L); + pendingDocument.setCommitHash("abc123"); + pendingDocument.setTaskId(TASK_ID); + pendingDocument.setMarkdownContent(GENERATED_QA_DOC); + pendingDocument.setGeneratedAt(OffsetDateTime.now()); + QaDocState state = new QaDocState(project, TASK_ID); + state.recordGeneration("previous-commit", null, 7L); + state.setLastGeneratedAt(OffsetDateTime.now().minusMinutes(5)); + when(qaDocStateRepository.findByProjectIdAndTaskId(PROJECT_ID, TASK_ID)) + .thenReturn(Optional.of(state)); + when(qaDocDocumentService.findLatestDocument(PROJECT_ID, 7L)) + .thenReturn(Optional.of(pendingDocument)); + + WebhookResult result = processor.process( + createPayload("feature/PROJ-123-add-login"), project, eventConsumer, Map.of()); + + assertThat(result.success()).isTrue(); + verify(qaDocGenerationService, never()).generateQaDocumentation( + any(), anyLong(), anyInt(), anyInt(), anyMap(), any()); + verify(qaDocDocumentService, never()).upsertLatestDocument( + any(), anyLong(), anyString(), any(), any(), anyString()); + verify(qaDocPublicPreviewService).createPreviewUrl(pendingDocument); + verify(taskManagementClient).postComment(eq(TASK_ID), contains("codecrow-qa-autodoc")); + verify(qaDocStateRepository).save(state); + } + + @Test + @DisplayName("should regenerate when the pending document belongs to another task") + void shouldNotReusePendingHandoffForAnotherTask() throws IOException { + setupHappyPath(); + QaDocDocument pendingDocument = new QaDocDocument(project, 7L); + pendingDocument.setCommitHash("abc123"); + pendingDocument.setTaskId("OTHER-456"); + pendingDocument.setMarkdownContent(GENERATED_QA_DOC); + pendingDocument.setGeneratedAt(OffsetDateTime.now()); + when(qaDocDocumentService.findLatestDocument(PROJECT_ID, 7L)) + .thenReturn(Optional.of(pendingDocument)); + + WebhookResult result = processor.process( + createPayload("feature/PROJ-123-add-login"), project, eventConsumer, Map.of()); + + assertThat(result.success()).isTrue(); + verify(qaDocGenerationService).generateQaDocumentation( + any(), anyLong(), anyInt(), anyInt(), anyMap(), any()); + verify(qaDocDocumentService).upsertLatestDocument( + eq(project), eq(7L), eq(TASK_ID), any(), eq("abc123"), anyString()); + } + @Test @DisplayName("should emit correct status events in order") void shouldEmitStatusEventsInOrder() throws IOException { @@ -749,12 +851,10 @@ void shouldHandleNullPrNumber() throws IOException { )).thenReturn("doc content"); when(taskManagementClient.findCommentByMarker(eq(TASK_ID), anyString())) .thenReturn(Optional.empty()); - when(taskManagementClient.postComment(eq(TASK_ID), anyString())) - .thenReturn(new TaskComment("c-1", "bot", "doc", OffsetDateTime.now(), null)); - WebhookResult result = processor.process(payload, project, eventConsumer, Map.of()); - assertThat(result.success()).isTrue(); + assertThat(result.success()).isFalse(); + assertThat(result.message()).contains("secure public preview"); // Should not attempt to look up analysis when PR number is null verify(codeAnalysisService, never()).getPreviousVersionCodeAnalysis(anyLong(), anyLong()); } diff --git a/java-ecosystem/services/pipeline-agent/src/test/java/org/rostilos/codecrow/pipelineagent/qadoc/QaAutoDocListenerTest.java b/java-ecosystem/services/pipeline-agent/src/test/java/org/rostilos/codecrow/pipelineagent/qadoc/QaAutoDocListenerTest.java index 5ba230f9..c6be90ea 100644 --- a/java-ecosystem/services/pipeline-agent/src/test/java/org/rostilos/codecrow/pipelineagent/qadoc/QaAutoDocListenerTest.java +++ b/java-ecosystem/services/pipeline-agent/src/test/java/org/rostilos/codecrow/pipelineagent/qadoc/QaAutoDocListenerTest.java @@ -19,6 +19,7 @@ import java.util.Map; import java.util.Optional; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -43,6 +44,8 @@ class QaAutoDocListenerTest { @Mock private QaDocDocumentService qaDocDocumentService; @Mock + private QaDocPublicPreviewService qaDocPublicPreviewService; + @Mock private PrFileEnrichmentService enrichmentService; @Mock private TokenEncryptionService tokenEncryptionService; @@ -58,6 +61,7 @@ void loadsProjectWithVcsConnectionsForAsyncProcessing() { vcsClientProvider, qaDocStateRepository, qaDocDocumentService, + qaDocPublicPreviewService, enrichmentService, tokenEncryptionService); AnalysisCompletedEvent event = new AnalysisCompletedEvent( @@ -81,4 +85,25 @@ void loadsProjectWithVcsConnectionsForAsyncProcessing() { verify(projectRepository).findByIdWithFullDetails(402L); verify(projectRepository, never()).findById(402L); } + + @Test + void distinguishesLegacyLinkOnlyCommentsFromRedactedFullDocuments() { + String previewUrl = "https://codecrow.cloud/share#token=ccs_opaque-token"; + + assertThat(QaAutoDocListener.isPublicPreviewOnlyComment(previewUrl)).isTrue(); + assertThat(QaAutoDocListener.isPublicPreviewOnlyComment( + "[View QA test cases in CodeCrow](" + previewUrl + ")")) + .isTrue(); + assertThat(QaAutoDocListener.isPublicPreviewOnlyComment(""" + ### 1. Change Summary + Login validation changed. + + ### 3. Test Scenarios + %s + + ### 4. Edge Cases + Verify an empty password. + """.formatted(previewUrl))) + .isFalse(); + } } diff --git a/java-ecosystem/services/pipeline-agent/src/test/java/org/rostilos/codecrow/pipelineagent/qadoc/QaDocHandoffRetryPolicyTest.java b/java-ecosystem/services/pipeline-agent/src/test/java/org/rostilos/codecrow/pipelineagent/qadoc/QaDocHandoffRetryPolicyTest.java new file mode 100644 index 00000000..020f3a55 --- /dev/null +++ b/java-ecosystem/services/pipeline-agent/src/test/java/org/rostilos/codecrow/pipelineagent/qadoc/QaDocHandoffRetryPolicyTest.java @@ -0,0 +1,65 @@ +package org.rostilos.codecrow.pipelineagent.qadoc; + +import org.junit.jupiter.api.Test; +import org.rostilos.codecrow.core.model.qadoc.QaDocDocument; +import org.rostilos.codecrow.core.model.qadoc.QaDocState; + +import java.time.OffsetDateTime; + +import static org.assertj.core.api.Assertions.assertThat; + +class QaDocHandoffRetryPolicyTest { + + @Test + void reusesCurrentCommitDocumentCreatedAfterTheLastSuccessfulHandoff() { + QaDocDocument document = document("commit-b", OffsetDateTime.parse("2026-08-12T10:05:00Z")); + QaDocState state = state("commit-a", OffsetDateTime.parse("2026-08-12T10:00:00Z")); + + assertThat(QaDocHandoffRetryPolicy.shouldReuse( + document, state, "commit-b", "task-1")).isTrue(); + } + + @Test + void regeneratesAfterASuccessfulHandoffOrForAnotherCommit() { + QaDocDocument delivered = document("commit-b", OffsetDateTime.parse("2026-08-12T10:00:00Z")); + QaDocState state = state("commit-b", OffsetDateTime.parse("2026-08-12T10:05:00Z")); + + assertThat(QaDocHandoffRetryPolicy.shouldReuse( + delivered, state, "commit-b", "TASK-1")).isFalse(); + assertThat(QaDocHandoffRetryPolicy.shouldReuse( + delivered, state, "commit-c", "TASK-1")).isFalse(); + } + + @Test + void doesNotReuseBlankDocument() { + QaDocDocument document = document("commit-a", OffsetDateTime.now()); + document.setMarkdownContent(" "); + + assertThat(QaDocHandoffRetryPolicy.shouldReuse( + document, null, "commit-a", "TASK-1")).isFalse(); + } + + @Test + void doesNotReuseDocumentGeneratedForAnotherTask() { + QaDocDocument document = document("commit-a", OffsetDateTime.now()); + + assertThat(QaDocHandoffRetryPolicy.shouldReuse( + document, null, "commit-a", "TASK-2")).isFalse(); + } + + private static QaDocDocument document(String commit, OffsetDateTime generatedAt) { + QaDocDocument document = new QaDocDocument(null, 17L); + document.setCommitHash(commit); + document.setGeneratedAt(generatedAt); + document.setMarkdownContent("# QA document"); + document.setTaskId("TASK-1"); + return document; + } + + private static QaDocState state(String commit, OffsetDateTime generatedAt) { + QaDocState state = new QaDocState(); + state.setLastCommitHash(commit); + state.setLastGeneratedAt(generatedAt); + return state; + } +} diff --git a/java-ecosystem/services/pipeline-agent/src/test/java/org/rostilos/codecrow/pipelineagent/qadoc/QaDocPublicPreviewServiceTest.java b/java-ecosystem/services/pipeline-agent/src/test/java/org/rostilos/codecrow/pipelineagent/qadoc/QaDocPublicPreviewServiceTest.java new file mode 100644 index 00000000..29eafb20 --- /dev/null +++ b/java-ecosystem/services/pipeline-agent/src/test/java/org/rostilos/codecrow/pipelineagent/qadoc/QaDocPublicPreviewServiceTest.java @@ -0,0 +1,99 @@ +package org.rostilos.codecrow.pipelineagent.qadoc; + +import org.junit.jupiter.api.Test; +import org.rostilos.codecrow.core.dto.admin.BaseUrlSettingsDTO; +import org.rostilos.codecrow.core.model.qadoc.QaDocDocument; +import org.rostilos.codecrow.core.service.SiteSettingsProvider; +import org.rostilos.codecrow.core.service.qadoc.QaDocPublicShareResource; +import org.rostilos.codecrow.publicshare.api.IssuedPublicShare; +import org.rostilos.codecrow.publicshare.service.PublicShareLinkService; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class QaDocPublicPreviewServiceTest { + + @Test + void createsAnOpaquePublicLinkAndRedactsShareableBodiesFromTheTaskComment() { + PublicShareLinkService publicShares = mock(PublicShareLinkService.class); + SiteSettingsProvider settings = mock(SiteSettingsProvider.class); + when(publicShares.issue(QaDocPublicShareResource.DOCUMENT, "88")) + .thenReturn(new IssuedPublicShare("ccs_opaque-public-credential")); + when(settings.getBaseUrlSettings()) + .thenReturn(new BaseUrlSettingsDTO( + "https://api.codecrow.example", + "https://app.codecrow.example/", + "https://hooks.codecrow.example" + )); + QaDocPublicPreviewService service = new QaDocPublicPreviewService(publicShares, settings); + QaDocDocument document = new QaDocDocument(null, 17L); + document.setId(88L); + document.setMarkdownContent(""" + ### 1. Change Summary + PRESERVED OVERVIEW CONTENT + + ### 2. Scope + PRESERVED SCOPE CONTENT + + + ### 3. Test Scenarios + **A shared scenario** (HIGH) + - **Expected Result:** REDACTED TEST DETAILS + + ### 4. Edge Cases + PRESERVED EDGE CASE CONTENT + + ### 5. Regression Risks + PRESERVED REGRESSION RISK CONTENT + + ### 6. Environment and Setup Notes + PRESERVED SETUP CONTENT + + + --- + *🐦 Generated by [CodeCrow](https://codecrow.app) QA Auto-Documentation* + + """); + + String previewUrl = service.createPreviewUrl(document); + String comment = service.buildTaskComment(document.getMarkdownContent(), previewUrl); + + assertThat(previewUrl) + .isEqualTo("https://app.codecrow.example/share#token=ccs_opaque-public-credential"); + assertThat(comment) + .contains(QaAutoDocListener.COMMENT_MARKER) + .contains("### 1. Change Summary", "PRESERVED OVERVIEW CONTENT") + .contains("### 2. Scope", "PRESERVED SCOPE CONTENT") + .contains("### 3. Test Scenarios\n\n" + previewUrl + "&tab=test-cases") + .contains("### 4. Edge Cases", "PRESERVED EDGE CASE CONTENT") + .contains("### 5. Regression Risks", "PRESERVED REGRESSION RISK CONTENT") + .contains( + "### 6. Environment and Setup Notes\n\n" + + previewUrl + "&tab=environment", + "Generated by [CodeCrow]", + "") + .doesNotContain( + "A shared scenario", + "REDACTED TEST DETAILS", + "PRESERVED SETUP CONTENT") + .doesNotContain("[View QA test cases in CodeCrow]"); + verify(publicShares).issue(QaDocPublicShareResource.DOCUMENT, "88"); + } + + @Test + void refusesToIssueALinkForAnUnmarkedDocument() { + PublicShareLinkService publicShares = mock(PublicShareLinkService.class); + SiteSettingsProvider settings = mock(SiteSettingsProvider.class); + QaDocPublicPreviewService service = new QaDocPublicPreviewService(publicShares, settings); + QaDocDocument document = new QaDocDocument(null, 17L); + document.setId(88L); + document.setMarkdownContent("SECRET OVERVIEW CONTENT"); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> service.createPreviewUrl(document)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("marked QA test-case section"); + } +} diff --git a/java-ecosystem/services/web-server/pom.xml b/java-ecosystem/services/web-server/pom.xml index dbe365c6..ddb172b1 100644 --- a/java-ecosystem/services/web-server/pom.xml +++ b/java-ecosystem/services/web-server/pom.xml @@ -77,6 +77,11 @@ codecrow-core
+ + org.rostilos.codecrow + codecrow-public-share + + org.rostilos.codecrow codecrow-commit-graph diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/analysis/dto/response/QaDocDocumentResponse.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/analysis/dto/response/QaDocDocumentResponse.java index 341af568..962780bc 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/analysis/dto/response/QaDocDocumentResponse.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/analysis/dto/response/QaDocDocumentResponse.java @@ -1,8 +1,11 @@ package org.rostilos.codecrow.webserver.analysis.dto.response; import org.rostilos.codecrow.core.model.qadoc.QaDocDocument; +import org.rostilos.codecrow.core.service.qadoc.QaDocContent; +import org.rostilos.codecrow.core.service.qadoc.QaDocContentParser; import java.time.OffsetDateTime; +import java.util.List; public record QaDocDocumentResponse( boolean available, @@ -11,13 +14,18 @@ public record QaDocDocumentResponse( Long lastAnalysisId, String commitHash, String markdownContent, + String overviewMarkdown, + List testCases, + String environmentMarkdown, OffsetDateTime generatedAt ) { public static QaDocDocumentResponse missing(Long prNumber) { - return new QaDocDocumentResponse(false, prNumber, null, null, null, null, null); + return new QaDocDocumentResponse( + false, prNumber, null, null, null, null, null, List.of(), null, null); } public static QaDocDocumentResponse fromDocument(QaDocDocument document) { + QaDocContent content = QaDocContentParser.parse(document.getMarkdownContent()); return new QaDocDocumentResponse( true, document.getPrNumber(), @@ -25,6 +33,9 @@ public static QaDocDocumentResponse fromDocument(QaDocDocument document) { document.getLastAnalysisId(), document.getCommitHash(), document.getMarkdownContent(), + content.overviewMarkdown(), + content.testCases().stream().map(QaDocTestCaseResponse::fromTestCase).toList(), + content.environmentMarkdown(), document.getGeneratedAt() ); } diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/analysis/dto/response/QaDocTestCaseResponse.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/analysis/dto/response/QaDocTestCaseResponse.java new file mode 100644 index 00000000..6837a850 --- /dev/null +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/analysis/dto/response/QaDocTestCaseResponse.java @@ -0,0 +1,20 @@ +package org.rostilos.codecrow.webserver.analysis.dto.response; + +import org.rostilos.codecrow.core.service.qadoc.QaDocTestCase; + +public record QaDocTestCaseResponse( + String title, + String priority, + String functionalArea, + String descriptionMarkdown +) { + public static QaDocTestCaseResponse fromTestCase(QaDocTestCase testCase) { + return new QaDocTestCaseResponse( + testCase.title(), + testCase.priority(), + testCase.functionalArea(), + testCase.descriptionMarkdown() + ); + } +} + diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/publicshare/PublicShareController.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/publicshare/PublicShareController.java new file mode 100644 index 00000000..14b8ab34 --- /dev/null +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/publicshare/PublicShareController.java @@ -0,0 +1,72 @@ +package org.rostilos.codecrow.webserver.publicshare; + +import org.rostilos.codecrow.publicshare.api.ResolvedPublicShare; +import org.rostilos.codecrow.publicshare.service.PublicShareLinkService; +import org.springframework.http.CacheControl; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +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.RestController; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +@RestController +@RequestMapping("/api/public/shares") +public class PublicShareController { + + private final PublicShareLinkService shareLinkService; + private final Map providers; + + public PublicShareController(PublicShareLinkService shareLinkService, + List providers) { + this.shareLinkService = shareLinkService; + this.providers = providers.stream().collect(Collectors.toUnmodifiableMap( + PublicShareResourceProvider::resourceType, + Function.identity() + )); + } + + @PostMapping("/resolve") + public ResponseEntity resolvePublicPreview(@RequestBody PublicShareResolveRequest request, + Authentication authentication) { + String token = request == null ? null : request.token(); + return shareLinkService.resolve(token) + .flatMap(share -> resolvePreview(share, authentication)) + .map(this::ok) + .orElseGet(this::notFound); + } + + private ResponseEntity ok(Object body) { + return ResponseEntity.ok() + .cacheControl(CacheControl.noStore()) + .header("Referrer-Policy", "no-referrer") + .body(body); + } + + private ResponseEntity notFound() { + return ResponseEntity.status(404) + .cacheControl(CacheControl.noStore()) + .header("Referrer-Policy", "no-referrer") + .build(); + } + + private java.util.Optional resolvePreview( + ResolvedPublicShare share, + Authentication authentication) { + PublicShareResourceProvider provider = providers.get(share.resourceType()); + if (provider == null) { + return java.util.Optional.empty(); + } + return provider.getPublicPreview(share.resourceKey()) + .map(content -> new PublicSharePreviewResponse( + share.resourceType(), + content, + provider.getAuthorizedPath(share.resourceKey(), authentication).orElse(null) + )); + } +} diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/publicshare/PublicSharePreviewResponse.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/publicshare/PublicSharePreviewResponse.java new file mode 100644 index 00000000..d59769c9 --- /dev/null +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/publicshare/PublicSharePreviewResponse.java @@ -0,0 +1,13 @@ +package org.rostilos.codecrow.webserver.publicshare; + +/** + * Generic public-share transport envelope. The content is always the provider's + * sanitized public DTO. The optional path is emitted only after normal tenant + * authorization succeeds for the authenticated principal. + */ +public record PublicSharePreviewResponse( + String resourceType, + Object content, + String authorizedPath +) { +} diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/publicshare/PublicShareResolveRequest.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/publicshare/PublicShareResolveRequest.java new file mode 100644 index 00000000..17c1cc3f --- /dev/null +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/publicshare/PublicShareResolveRequest.java @@ -0,0 +1,4 @@ +package org.rostilos.codecrow.webserver.publicshare; + +public record PublicShareResolveRequest(String token) { +} diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/publicshare/PublicShareResourceProvider.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/publicshare/PublicShareResourceProvider.java new file mode 100644 index 00000000..1a505f5b --- /dev/null +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/publicshare/PublicShareResourceProvider.java @@ -0,0 +1,21 @@ +package org.rostilos.codecrow.webserver.publicshare; + +import org.springframework.security.core.Authentication; + +import java.util.Optional; + +/** Resolves one internal resource type into its explicitly sanitized public DTO. */ +public interface PublicShareResourceProvider { + + String resourceType(); + + Optional getPublicPreview(String resourceKey); + + /** + * Returns a protected in-app destination only when the current principal is + * authorized for the underlying tenant resource. + */ + default Optional getAuthorizedPath(String resourceKey, Authentication authentication) { + return Optional.empty(); + } +} diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/publicshare/qadoc/QaDocPublicPreview.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/publicshare/qadoc/QaDocPublicPreview.java new file mode 100644 index 00000000..6b55c3c5 --- /dev/null +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/publicshare/qadoc/QaDocPublicPreview.java @@ -0,0 +1,21 @@ +package org.rostilos.codecrow.webserver.publicshare.qadoc; + +import org.rostilos.codecrow.webserver.analysis.dto.response.QaDocTestCaseResponse; + +import java.util.List; + +/** + * QA content returned only after a public-share credential resolves. It mirrors + * the QA document tabs while excluding workspace details and internal document, + * project, pull-request, analysis, and commit identifiers. + */ +public record QaDocPublicPreview( + String title, + String projectName, + String taskKey, + String taskSummary, + String overviewMarkdown, + List testCases, + String environmentMarkdown +) { +} diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/publicshare/qadoc/QaDocShareProvider.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/publicshare/qadoc/QaDocShareProvider.java new file mode 100644 index 00000000..07769e1b --- /dev/null +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/publicshare/qadoc/QaDocShareProvider.java @@ -0,0 +1,155 @@ +package org.rostilos.codecrow.webserver.publicshare.qadoc; + +import org.rostilos.codecrow.core.model.codeanalysis.CodeAnalysis; +import org.rostilos.codecrow.core.model.project.Project; +import org.rostilos.codecrow.core.model.qadoc.QaDocDocument; +import org.rostilos.codecrow.core.service.CodeAnalysisService; +import org.rostilos.codecrow.core.service.QaDocDocumentService; +import org.rostilos.codecrow.core.service.qadoc.QaDocContent; +import org.rostilos.codecrow.core.service.qadoc.QaDocContentParser; +import org.rostilos.codecrow.core.service.qadoc.QaDocPublicShareResource; +import org.rostilos.codecrow.security.service.UserDetailsImpl; +import org.rostilos.codecrow.security.web.WorkspaceSecurity; +import org.rostilos.codecrow.webserver.analysis.dto.response.QaDocTestCaseResponse; +import org.rostilos.codecrow.webserver.publicshare.PublicShareResourceProvider; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.util.UriComponentsBuilder; + +import java.util.Objects; +import java.util.Optional; + +/** Resolves the explicitly shareable Overview, Test cases, and Environment QA tabs. */ +@Component +public class QaDocShareProvider implements PublicShareResourceProvider { + + private final QaDocDocumentService qaDocDocumentService; + private final CodeAnalysisService codeAnalysisService; + private final WorkspaceSecurity workspaceSecurity; + + public QaDocShareProvider(QaDocDocumentService qaDocDocumentService, + CodeAnalysisService codeAnalysisService, + WorkspaceSecurity workspaceSecurity) { + this.qaDocDocumentService = qaDocDocumentService; + this.codeAnalysisService = codeAnalysisService; + this.workspaceSecurity = workspaceSecurity; + } + + @Override + public String resourceType() { + return QaDocPublicShareResource.DOCUMENT; + } + + @Override + @Transactional(readOnly = true) + public Optional getPublicPreview(String resourceKey) { + return parseDocumentId(resourceKey) + .flatMap(qaDocDocumentService::findDocumentById) + .flatMap(this::toPublicPreview); + } + + @Override + @Transactional(readOnly = true) + public Optional getAuthorizedPath(String resourceKey, Authentication authentication) { + if (authentication == null || !authentication.isAuthenticated() + || !(authentication.getPrincipal() instanceof UserDetailsImpl)) { + return Optional.empty(); + } + + return parseDocumentId(resourceKey) + .flatMap(qaDocDocumentService::findDocumentById) + .flatMap(document -> authorizedDocumentPath(document, authentication)); + } + + private Optional toPublicPreview(QaDocDocument document) { + var testCases = QaDocContentParser.parseMarkedTestCases(document.getMarkdownContent()); + if (testCases.isEmpty()) { + return Optional.empty(); + } + QaDocContent content = QaDocContentParser.parse(document.getMarkdownContent()); + + String projectName = document.getProject() == null + ? null + : normalize(document.getProject().getName()); + String taskKey = normalize(document.getTaskId()); + + return Optional.of(new QaDocPublicPreview( + "QA documentation", + projectName, + taskKey, + findTaskSummary(document), + content.overviewMarkdown(), + testCases.stream() + .map(QaDocTestCaseResponse::fromTestCase) + .toList(), + content.environmentMarkdown() + )); + } + + private Optional authorizedDocumentPath( + QaDocDocument document, + Authentication authentication) { + Project project = document.getProject(); + if (project == null || project.getId() == null || project.getWorkspace() == null + || !workspaceSecurity.isProjectWorkspaceMember(project.getId(), authentication)) { + return Optional.empty(); + } + + String workspaceSlug = normalize(project.getWorkspace().getSlug()); + String projectNamespace = normalize(project.getNamespace()); + if (workspaceSlug == null || projectNamespace == null || document.getPrNumber() == null) { + return Optional.empty(); + } + + return Optional.of(UriComponentsBuilder + .fromPath("/dashboard/{workspaceSlug}/projects/{projectNamespace}") + .queryParam("prNumber", document.getPrNumber()) + .queryParam("subTab", "qa-doc") + .buildAndExpand(workspaceSlug, projectNamespace) + .encode() + .toUriString()); + } + + private String findTaskSummary(QaDocDocument document) { + if (document.getLastAnalysisId() == null || document.getProject() == null + || document.getProject().getId() == null) { + return null; + } + + Long documentProjectId = document.getProject().getId(); + String documentTaskKey = normalize(document.getTaskId()); + return codeAnalysisService.findById(document.getLastAnalysisId()) + .filter(analysis -> belongsToProject(analysis, documentProjectId)) + .filter(analysis -> hasCompatibleTaskKey(analysis, documentTaskKey)) + .map(CodeAnalysis::getTaskSummary) + .map(QaDocShareProvider::normalize) + .orElse(null); + } + + private boolean belongsToProject(CodeAnalysis analysis, Long projectId) { + return analysis.getProject() != null + && Objects.equals(analysis.getProject().getId(), projectId); + } + + private boolean hasCompatibleTaskKey(CodeAnalysis analysis, String documentTaskKey) { + String analysisTaskKey = normalize(analysis.getTaskId()); + return documentTaskKey == null || analysisTaskKey == null + || Objects.equals(documentTaskKey, analysisTaskKey); + } + + private static String normalize(String value) { + if (value == null || value.isBlank()) { + return null; + } + return value.trim(); + } + + private static Optional parseDocumentId(String resourceKey) { + try { + return Optional.of(Long.valueOf(resourceKey)); + } catch (NumberFormatException ignored) { + return Optional.empty(); + } + } +} diff --git a/java-ecosystem/services/web-server/src/test/java/org/rostilos/codecrow/webserver/publicshare/PublicShareControllerTest.java b/java-ecosystem/services/web-server/src/test/java/org/rostilos/codecrow/webserver/publicshare/PublicShareControllerTest.java new file mode 100644 index 00000000..b9cf6c16 --- /dev/null +++ b/java-ecosystem/services/web-server/src/test/java/org/rostilos/codecrow/webserver/publicshare/PublicShareControllerTest.java @@ -0,0 +1,63 @@ +package org.rostilos.codecrow.webserver.publicshare; + +import org.junit.jupiter.api.Test; +import org.rostilos.codecrow.publicshare.api.ResolvedPublicShare; +import org.rostilos.codecrow.publicshare.service.PublicShareLinkService; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class PublicShareControllerTest { + + @Test + void resolvesThroughTheRegisteredSanitizingProvider() { + PublicShareLinkService links = mock(PublicShareLinkService.class); + PublicShareResourceProvider provider = mock(PublicShareResourceProvider.class); + Authentication authentication = mock(Authentication.class); + when(provider.resourceType()).thenReturn("safe-preview"); + doReturn(Optional.of(Map.of("title", "Shared content"))) + .when(provider).getPublicPreview("internal-9"); + when(provider.getAuthorizedPath("internal-9", authentication)) + .thenReturn(Optional.of("/dashboard/acme/projects/shop?prNumber=9&subTab=qa-doc")); + when(links.resolve("ccs_public-token")) + .thenReturn(Optional.of(new ResolvedPublicShare("safe-preview", "internal-9"))); + PublicShareController controller = new PublicShareController(links, List.of(provider)); + + ResponseEntity response = controller.resolvePublicPreview( + new PublicShareResolveRequest("ccs_public-token"), + authentication + ); + + assertThat(response.getStatusCode().value()).isEqualTo(200); + assertThat(response.getBody()).isEqualTo(new PublicSharePreviewResponse( + "safe-preview", + Map.of("title", "Shared content"), + "/dashboard/acme/projects/shop?prNumber=9&subTab=qa-doc" + )); + assertThat(response.getHeaders().getCacheControl()).isEqualTo("no-store"); + assertThat(response.getHeaders().getFirst("Referrer-Policy")).isEqualTo("no-referrer"); + } + + @Test + void usesTheSameNotFoundResponseForInvalidAndUnsupportedTokens() { + PublicShareLinkService links = mock(PublicShareLinkService.class); + PublicShareResourceProvider provider = mock(PublicShareResourceProvider.class); + when(provider.resourceType()).thenReturn("safe-preview"); + when(links.resolve("invalid")).thenReturn(Optional.empty()); + PublicShareController controller = new PublicShareController(links, List.of(provider)); + + assertThat(controller.resolvePublicPreview(new PublicShareResolveRequest("invalid"), null) + .getStatusCode().value()).isEqualTo(404); + verify(provider, never()).getPublicPreview(org.mockito.ArgumentMatchers.anyString()); + } +} diff --git a/java-ecosystem/services/web-server/src/test/java/org/rostilos/codecrow/webserver/publicshare/qadoc/QaDocShareProviderTest.java b/java-ecosystem/services/web-server/src/test/java/org/rostilos/codecrow/webserver/publicshare/qadoc/QaDocShareProviderTest.java new file mode 100644 index 00000000..6af3a1f2 --- /dev/null +++ b/java-ecosystem/services/web-server/src/test/java/org/rostilos/codecrow/webserver/publicshare/qadoc/QaDocShareProviderTest.java @@ -0,0 +1,170 @@ +package org.rostilos.codecrow.webserver.publicshare.qadoc; + +import org.junit.jupiter.api.Test; +import org.rostilos.codecrow.core.model.codeanalysis.CodeAnalysis; +import org.rostilos.codecrow.core.model.project.Project; +import org.rostilos.codecrow.core.model.qadoc.QaDocDocument; +import org.rostilos.codecrow.core.model.workspace.Workspace; +import org.rostilos.codecrow.core.service.CodeAnalysisService; +import org.rostilos.codecrow.core.service.QaDocDocumentService; +import org.rostilos.codecrow.security.service.UserDetailsImpl; +import org.rostilos.codecrow.security.web.WorkspaceSecurity; +import org.springframework.security.core.Authentication; + +import java.util.Arrays; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class QaDocShareProviderTest { + + @Test + void returnsTheExplicitlyShareableDocumentTabs() { + QaDocDocumentService documents = mock(QaDocDocumentService.class); + CodeAnalysisService analyses = mock(CodeAnalysisService.class); + WorkspaceSecurity workspaceSecurity = mock(WorkspaceSecurity.class); + Project project = mock(Project.class); + when(project.getId()).thenReturn(12L); + when(project.getName()).thenReturn("Acme Checkout"); + + QaDocDocument document = new QaDocDocument(project, 17L); + document.setId(88L); + document.setTaskId("SHOP-42"); + document.setLastAnalysisId(71L); + document.setCommitHash("0123456789012345678901234567890123456789"); + document.setMarkdownContent(""" + # QA Testing Guide — Saved cards + ## 1. What Changed + Customers can reuse a saved card. + + + ### Test Scenarios + **Save the form** (HIGH) + - **Expected Result:** The confirmation appears + + + ## 6. Setup and Environment Notes + - Enable saved cards in the QA environment. + """); + CodeAnalysis analysis = mock(CodeAnalysis.class); + when(analysis.getProject()).thenReturn(project); + when(analysis.getTaskId()).thenReturn("SHOP-42"); + when(analysis.getTaskSummary()).thenReturn("Add a saved-card checkout flow"); + when(documents.findDocumentById(88L)).thenReturn(Optional.of(document)); + when(analyses.findById(71L)).thenReturn(Optional.of(analysis)); + QaDocShareProvider provider = new QaDocShareProvider( + documents, analyses, workspaceSecurity); + + QaDocPublicPreview preview = provider.getPublicPreview("88").orElseThrow(); + + assertThat(preview.title()).isEqualTo("QA documentation"); + assertThat(preview.projectName()).isEqualTo("Acme Checkout"); + assertThat(preview.taskKey()).isEqualTo("SHOP-42"); + assertThat(preview.taskSummary()).isEqualTo("Add a saved-card checkout flow"); + assertThat(preview.overviewMarkdown()) + .contains("What Changed", "Customers can reuse a saved card") + .doesNotContain("Save the form", "Setup and Environment Notes"); + assertThat(preview.testCases()).singleElement() + .satisfies(testCase -> assertThat(testCase.title()).isEqualTo("Save the form")); + assertThat(preview.environmentMarkdown()) + .isEqualTo("- Enable saved cards in the QA environment."); + assertThat(Arrays.stream(QaDocPublicPreview.class.getRecordComponents()) + .map(component -> component.getName())) + .containsExactly( + "title", "projectName", "taskKey", "taskSummary", + "overviewMarkdown", "testCases", "environmentMarkdown") + .doesNotContain("id", "project", "workspace", "prNumber", "taskId", "commitHash"); + } + + @Test + void neverFallsBackToSharingAnUnmarkedLegacyDocument() { + QaDocDocumentService documents = mock(QaDocDocumentService.class); + CodeAnalysisService analyses = mock(CodeAnalysisService.class); + WorkspaceSecurity workspaceSecurity = mock(WorkspaceSecurity.class); + QaDocDocument document = new QaDocDocument(null, 17L); + document.setId(89L); + document.setMarkdownContent(""" + # Secret project overview + Workspace: Acme + **A legacy test** (HIGH) + - **Expected Result:** It works + """); + when(documents.findDocumentById(89L)).thenReturn(Optional.of(document)); + QaDocShareProvider provider = new QaDocShareProvider( + documents, analyses, workspaceSecurity); + + assertThat(provider.getPublicPreview("89")).isEmpty(); + } + + @Test + void doesNotExposeTaskSummaryFromAnotherProject() { + QaDocDocumentService documents = mock(QaDocDocumentService.class); + CodeAnalysisService analyses = mock(CodeAnalysisService.class); + WorkspaceSecurity workspaceSecurity = mock(WorkspaceSecurity.class); + Project documentProject = mock(Project.class); + Project analysisProject = mock(Project.class); + when(documentProject.getId()).thenReturn(12L); + when(documentProject.getName()).thenReturn("Checkout"); + when(analysisProject.getId()).thenReturn(99L); + + QaDocDocument document = new QaDocDocument(documentProject, 17L); + document.setId(90L); + document.setTaskId("SHOP-42"); + document.setLastAnalysisId(72L); + document.setMarkdownContent(""" + + ### Test Scenarios + **Save the form** (HIGH) + - **Expected Result:** The confirmation appears + + """); + CodeAnalysis analysis = mock(CodeAnalysis.class); + when(analysis.getProject()).thenReturn(analysisProject); + when(analysis.getTaskId()).thenReturn("SHOP-42"); + when(analysis.getTaskSummary()).thenReturn("Secret from another project"); + when(documents.findDocumentById(90L)).thenReturn(Optional.of(document)); + when(analyses.findById(72L)).thenReturn(Optional.of(analysis)); + + QaDocPublicPreview preview = new QaDocShareProvider( + documents, analyses, workspaceSecurity) + .getPublicPreview("90") + .orElseThrow(); + + assertThat(preview.projectName()).isEqualTo("Checkout"); + assertThat(preview.taskKey()).isEqualTo("SHOP-42"); + assertThat(preview.taskSummary()).isNull(); + } + + @Test + void returnsTheRealQaDocRouteOnlyForAnAuthorizedWorkspaceMember() { + QaDocDocumentService documents = mock(QaDocDocumentService.class); + CodeAnalysisService analyses = mock(CodeAnalysisService.class); + WorkspaceSecurity workspaceSecurity = mock(WorkspaceSecurity.class); + Project project = mock(Project.class); + Workspace workspace = mock(Workspace.class); + Authentication authentication = mock(Authentication.class); + UserDetailsImpl principal = mock(UserDetailsImpl.class); + when(authentication.isAuthenticated()).thenReturn(true); + when(authentication.getPrincipal()).thenReturn(principal); + when(project.getId()).thenReturn(12L); + when(project.getNamespace()).thenReturn("checkout-service"); + when(project.getWorkspace()).thenReturn(workspace); + when(workspace.getSlug()).thenReturn("acme"); + when(workspaceSecurity.isProjectWorkspaceMember(12L, authentication)).thenReturn(true); + + QaDocDocument document = new QaDocDocument(project, 524L); + document.setId(91L); + when(documents.findDocumentById(91L)).thenReturn(Optional.of(document)); + QaDocShareProvider provider = new QaDocShareProvider( + documents, analyses, workspaceSecurity); + + assertThat(provider.getAuthorizedPath("91", authentication)) + .contains("/dashboard/acme/projects/checkout-service?prNumber=524&subTab=qa-doc"); + + when(workspaceSecurity.isProjectWorkspaceMember(12L, authentication)).thenReturn(false); + assertThat(provider.getAuthorizedPath("91", authentication)).isEmpty(); + assertThat(provider.getAuthorizedPath("91", null)).isEmpty(); + } +} diff --git a/python-ecosystem/inference-orchestrator/src/api/routers/qa_documentation.py b/python-ecosystem/inference-orchestrator/src/api/routers/qa_documentation.py index cbfdd63b..615bbb77 100644 --- a/python-ecosystem/inference-orchestrator/src/api/routers/qa_documentation.py +++ b/python-ecosystem/inference-orchestrator/src/api/routers/qa_documentation.py @@ -1,8 +1,8 @@ """ QA Auto-Documentation Router. -Generates QA-oriented documentation for PR changes, intended to be posted -as a comment on the linked task management ticket (e.g., Jira). +Generates QA-oriented documentation for PR changes. The full document is kept +in CodeCrow; task-management comments receive a public test-case preview link. Supports both single-pass (legacy / small PRs) and multi-stage ULTRATHINKING pipeline (large PRs with enrichment data). @@ -103,7 +103,7 @@ async def generate_qa_documentation( 2. Determines if documentation is needed (LLM decides). 3. For large PRs: runs 3-stage ULTRATHINKING pipeline (batch → cross-impact → aggregate). 4. For small PRs: runs single-pass generation. - 5. Returns the document text ready to be posted as a task comment. + 5. Returns the document text for CodeCrow storage and test-case extraction. """ logger.info( "QA documentation request: project=%s, pr=#%s, mode=%s, diff_size=%d, enrichment=%s, delta=%s", diff --git a/python-ecosystem/inference-orchestrator/src/service/qa_documentation/qa_doc_orchestrator.py b/python-ecosystem/inference-orchestrator/src/service/qa_documentation/qa_doc_orchestrator.py index 1749f935..ddbff833 100644 --- a/python-ecosystem/inference-orchestrator/src/service/qa_documentation/qa_doc_orchestrator.py +++ b/python-ecosystem/inference-orchestrator/src/service/qa_documentation/qa_doc_orchestrator.py @@ -11,6 +11,7 @@ import asyncio import json import logging +import re from typing import Dict, Any, List, Optional, Callable, Set from model.enrichment import PrEnrichmentDataDto @@ -35,6 +36,7 @@ QA_STAGE_3_AGGREGATION_PROMPT, QA_STAGE_3_DELTA_PROMPT, QA_STAGE_3_PREVIOUS_DOC_SECTION, + QA_DOC_TEST_CASES_REPAIR_PROMPT, ) logger = logging.getLogger(__name__) @@ -151,6 +153,12 @@ async def run( logger.warning("QA doc generation produced empty/short output") return {"documentation_needed": False, "documentation": None} + documentation = await self._ensure_test_cases(documentation, placeholders) + documentation = self._normalize_document_title( + documentation, + placeholders["pr_title"], + ) + # ── Footer with PR tracking ────────────────────────────────── documented_prs = self._extract_documented_prs(previous_documentation) if pr_number: @@ -715,6 +723,167 @@ async def _run_single_pass( return content + async def _ensure_test_cases( + self, + documentation: str, + placeholders: Dict[str, str], + ) -> str: + """Guarantee an independently extractable test-case section. + + Normal generation is instructed to emit stable invisible markers. If a + custom template or model response omits them, one focused repair call + generates only the missing section without narrowing the requested test + coverage. + """ + if self._contains_extractable_test_cases(documentation): + return self._normalize_test_case_markers(documentation) + + repair_placeholders = dict(placeholders) + raw_diff = repair_placeholders.get("diff", "") + max_repair_diff = 120_000 + if len(raw_diff) > max_repair_diff: + repair_placeholders["diff"] = ( + raw_diff[:max_repair_diff] + + f"\n\n... (diff truncated — {len(raw_diff)} chars total, " + f"showing first {max_repair_diff})" + ) + + logger.warning("QA doc omitted extractable test cases; running focused repair generation") + prompt = QA_DOC_TEST_CASES_REPAIR_PROMPT.format(**repair_placeholders) + response = await self.llm.ainvoke([ + {"role": "system", "content": QA_DOC_SYSTEM_PROMPT}, + {"role": "user", "content": prompt}, + ]) + generated = self._extract_text(response).strip() + if not generated: + raise ValueError("Test-case generation returned no content") + + start_marker = "" + end_marker = "" + start = generated.find(start_marker) + end = generated.find(end_marker, start + len(start_marker)) if start >= 0 else -1 + if start >= 0 and end >= 0: + test_case_section = generated[start:end + len(end_marker)] + else: + test_case_section = f"{start_marker}\n{generated}\n{end_marker}" + + if not self._contains_extractable_test_cases(test_case_section): + raise ValueError("Test-case generation returned no structured scenarios") + + return self._normalize_test_case_markers( + documentation.rstrip() + "\n\n" + test_case_section.strip() + ) + + @staticmethod + def _normalize_test_case_markers(documentation: str) -> str: + """Keep later peer sections outside the test-case disclosure boundary. + + A model can occasionally place the closing marker at the end of the + document despite the prompt. Move it before the next heading at the + Test Scenarios heading level so sections such as Edge Cases, + Regression Risks, and Environment Notes remain independent. + """ + start_marker = "" + end_marker = "" + start = documentation.find(start_marker) + if start < 0: + return documentation + content_start = start + len(start_marker) + end = documentation.find(end_marker, content_start) + if end < 0: + return documentation + + marked_content = documentation[content_start:end] + test_heading = re.search( + r"(?mi)^\s*(#{2,6})\s+(?:\d+\.\s*)?" + r"Test Scenarios(?:\s+by Area)?\s*$", + marked_content, + ) + if test_heading is None: + return documentation + + heading_level = len(test_heading.group(1)) + following = marked_content[test_heading.end():] + peer_heading = None + for candidate in re.finditer( + r"(?m)^\s*(#{2,6})\s+(.+?)\s*$", + following, + ): + candidate_level = len(candidate.group(1)) + candidate_title = candidate.group(2).strip() + numbered_section = re.match(r"^\d+\.\s+.+$", candidate_title) is not None + known_later_section = re.match( + r"(?i)^(?:Edge Cases(?: and Negative Testing)?|Negative Testing|" + r"Regression Risks|Environment(?: and Setup Notes)?|Setup Notes).*$", + candidate_title, + ) is not None + if ( + candidate_level < heading_level + or ( + candidate_level == heading_level + and (numbered_section or known_later_section) + ) + ): + peer_heading = test_heading.end() + candidate.start() + break + if peer_heading is None: + return documentation + + test_content = marked_content[:peer_heading].strip() + later_sections = marked_content[peer_heading:].strip() + after_marker = documentation[end + len(end_marker):].strip() + + normalized = ( + documentation[:content_start].rstrip() + + "\n" + + test_content + + "\n" + + end_marker + + "\n\n" + + later_sections + ) + if after_marker: + normalized += "\n\n" + after_marker + return normalized.strip() + + @staticmethod + def _contains_extractable_test_cases(documentation: Optional[str]) -> bool: + if not documentation: + return False + start_marker = "" + end_marker = "" + start = documentation.find(start_marker) + if start < 0: + return False + content_start = start + len(start_marker) + end = documentation.find(end_marker, content_start) + if end < 0: + return False + marked_content = documentation[content_start:end] + return re.search( + r"(?mi)^\s*\*\*.+?\*\*\s*\((?:HIGH|MEDIUM|LOW)\)", + marked_content, + ) is not None + + @staticmethod + def _normalize_document_title(documentation: str, fallback_title: str) -> str: + """Replace a leaked empty-title sentinel in the rendered guide heading.""" + return re.sub( + r"(?mi)^(#\s+QA Testing Guide\s*[—–-]\s*)(?:N\s*/?\s*A|None|null)\s*$", + lambda match: f"{match.group(1)}{fallback_title}", + documentation, + count=1, + ) + + @staticmethod + def _display_value(value: Any) -> Optional[str]: + if value is None: + return None + normalized = str(value).strip() + if not normalized or normalized.casefold() in {"n/a", "na", "none", "null"}: + return None + return normalized + # ================================================================== # Shared helpers # ================================================================== @@ -763,14 +932,25 @@ def _build_placeholders( """Build the placeholder dictionary used for prompt formatting.""" task_ctx = task_context_dict or {} effective_language = output_language if output_language and output_language.strip() else "English" + normalized_project_name = self._display_value(project_name) + task_key = self._display_value(task_ctx.get("task_key")) + task_summary = self._display_value(task_ctx.get("task_summary")) + pr_title = ( + self._display_value(pr_metadata.get("prTitle")) + or task_summary + or task_key + or (f"PR #{pr_number}" if pr_number is not None else None) + or normalized_project_name + or "QA documentation" + ) return { - "project_name": project_name or "Unknown", + "project_name": normalized_project_name or "Unknown", "pr_number": str(pr_number) if pr_number else "N/A", - "task_key": task_ctx.get("task_key", "N/A"), - "task_summary": task_ctx.get("task_summary", "N/A"), + "task_key": task_key or "N/A", + "task_summary": task_summary or "N/A", "source_branch": source_branch, "target_branch": target_branch, - "pr_title": pr_metadata.get("prTitle", "N/A"), + "pr_title": pr_title, "pr_description": self._truncate(pr_metadata.get("prDescription", ""), 500), "issues_found": str(issues_found), "files_analyzed": str(files_analyzed), diff --git a/python-ecosystem/inference-orchestrator/src/utils/prompts/constants_qa_doc.py b/python-ecosystem/inference-orchestrator/src/utils/prompts/constants_qa_doc.py index b7e259f9..c4b870dc 100644 --- a/python-ecosystem/inference-orchestrator/src/utils/prompts/constants_qa_doc.py +++ b/python-ecosystem/inference-orchestrator/src/utils/prompts/constants_qa_doc.py @@ -123,7 +123,20 @@ - NEVER include SQL queries, API endpoints, CLI commands, database operations, or configuration references. - Write everything from the USER's perspective — what screens, buttons, and behaviors to test. - Every scenario must have numbered steps a manual tester can follow. -- Translate all technical changes into user-visible behaviors. If a change is purely internal, say "Verify existing functionality still works" instead of describing the code.""" +- Translate all technical changes into user-visible behaviors. If a change is purely internal, say "Verify existing functionality still works" instead of describing the code. + +The output MUST include test cases even when another template or structure is requested. +Wrap the complete test-case section in these exact invisible markers: + +### Test Scenarios +**Scenario Name** (HIGH) +- **Preconditions:** Required setup +- **Steps:** + 1. Tester action +- **Expected Result:** Observable result + +Use exactly HIGH, MEDIUM, or LOW as each scenario's priority. +Do not place non-test-case content between those markers.""" QA_DOC_BASE_PROMPT = """Generate structured QA documentation for the following PR changes. @@ -170,16 +183,19 @@ - What the user will notice is different - Expected behavior after the change + ### 3. Test Scenarios For each functional area, list test scenarios using this format: -**Scenario Name** (PRIORITY) +**Scenario Name** (HIGH) - **Preconditions:** What needs to be set up before testing - **Steps:** 1. Go to [screen/page] 2. Click [button/link] 3. ... - **Expected Result:** What the tester should see/verify + +Use exactly HIGH, MEDIUM, or LOW as each scenario's priority. ### 4. Edge Cases and Negative Testing Bullet list of boundary conditions and error scenarios to verify, written as user actions. @@ -223,13 +239,27 @@ - Use only: headings (#), bold (**), bullet lists (-), numbered lists (1.) ## Custom Template Instructions -Follow this user-provided template as closely as possible: +Follow this user-provided template as closely as possible for the overview content: --- {custom_template} --- -Generate the QA documentation now, following the custom template above.""" +The custom template MUST NOT remove test cases. After its content, append a complete +test-case section in the exact format below, even when the custom template does not +request scenarios or asks for a different document structure: + + +### Test Scenarios +**Scenario Name** (HIGH) +- **Preconditions:** Required setup +- **Steps:** + 1. Tester action +- **Expected Result:** Observable result + +Use exactly HIGH, MEDIUM, or LOW as each scenario's priority. + +Do not place overview content between the markers. Generate the QA documentation now.""" # --------------------------------------------------------------------------- @@ -424,7 +454,7 @@ - Horizontal rules: --- 5. For test scenarios, use this exact bullet-list format (NOT a table): -**Scenario Name** (PRIORITY) +**Scenario Name** (HIGH) - **Preconditions:** What setup is needed - **Steps:** 1. Navigate to [page/screen] @@ -432,6 +462,8 @@ 3. Verify [result] - **Expected Result:** What the tester should observe +Use exactly HIGH, MEDIUM, or LOW as each scenario's priority. + 6. If a change is purely internal (refactoring, infrastructure, CI/CD), write: "Verify [feature] still works correctly" — do NOT describe the code change itself. ## Instructions @@ -458,11 +490,13 @@ (Skip this section entirely if no acceptance criteria were provided.) + ## 3. Test Scenarios by Area Group scenarios under the functional area heading (### Area Name). List each scenario using the bullet-list format shown above. Order: HIGH priority first, then MEDIUM, then LOW. + ## 4. Edge Cases and Negative Testing Bullet list of unusual conditions to verify: @@ -473,7 +507,7 @@ Areas of the application that were NOT changed but might be affected: - [Feature/screen] — why it might be impacted, how to verify -## 6. Setup and Environment Notes +## 6. Environment and Setup Notes Any special requirements: - Test data needed - Configuration or feature flags to enable @@ -527,12 +561,47 @@ 5. **Marking** new/updated sections with *(updated in latest push)* so testers see what's new 6. **Preserving** all scenarios from the previous guide that are still valid 7. **Stripping any technical references** that may have leaked — replace with user-facing descriptions +8. **Preserving the exact `` and `` markers** around all test scenarios. If the previous guide has no markers, add them. The result must be a COMPLETE, standalone QA testing guide — not just the changes. Generate the updated document now. Remember: NO TABLES, NO FILE NAMES, NO CODE, NO SQL, NO API ENDPOINTS, NO CLI COMMANDS.""" +QA_DOC_TEST_CASES_REPAIR_PROMPT = """Generate ONLY the missing manual test-case section for this PR. +Write all user-facing text in **{output_language}**. Keep the existing breadth and +detail of the QA guidance; do not summarize or reduce scenarios because the change is large. + +PR #{pr_number} in {project_name}: {pr_title} +Task: {task_key} — {task_summary} +Branch: {source_branch} → {target_branch} + +{task_context} + +Analysis summary: +{analysis_summary} + +PR diff: +``` +{diff} +``` + +Return only this structure, with one or more concrete scenarios: + +### Test Scenarios +**Scenario Name** (HIGH) +- **Preconditions:** Required setup +- **Steps:** + 1. Tester action +- **Expected Result:** Observable result + + +Use exactly HIGH, MEDIUM, or LOW as each scenario's priority. + +Never include file names, code symbols, SQL, endpoints, commands, configuration +files, code snippets, or implementation-stack details.""" + + # --------------------------------------------------------------------------- # Update preamble — injected when previous QA documentation already exists # for the same task from earlier PRs. The LLM merges old + new into one doc. diff --git a/python-ecosystem/inference-orchestrator/tests/test_qa_documentation.py b/python-ecosystem/inference-orchestrator/tests/test_qa_documentation.py index 481b51cf..553d6d6d 100644 --- a/python-ecosystem/inference-orchestrator/tests/test_qa_documentation.py +++ b/python-ecosystem/inference-orchestrator/tests/test_qa_documentation.py @@ -9,7 +9,7 @@ """ import pytest import json -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock from service.qa_documentation.qa_doc_orchestrator import QaDocOrchestrator from service.qa_documentation.base_orchestrator import ( @@ -18,6 +18,7 @@ emit_progress, emit_error, ) +from utils.prompts.constants_qa_doc import QA_DOC_CUSTOM_PROMPT # ── emit_status / emit_progress / emit_error ───────────────────── @@ -209,6 +210,122 @@ def test_compact_format(self): assert " " not in result # Compact separators +class TestIndependentTestCases: + def test_custom_template_cannot_remove_test_cases(self): + assert "custom template MUST NOT remove test cases" in QA_DOC_CUSTOM_PROMPT + assert "" in QA_DOC_CUSTOM_PROMPT + assert "" in QA_DOC_CUSTOM_PROMPT + + def test_detects_only_marked_structured_scenarios(self): + valid = """ + + **Checkout succeeds** (HIGH) + - **Expected Result:** Confirmation appears + + """ + assert QaDocOrchestrator._contains_extractable_test_cases(valid) + assert not QaDocOrchestrator._contains_extractable_test_cases( + "**Checkout succeeds** (HIGH)" + ) + assert not QaDocOrchestrator._contains_extractable_test_cases(""" + + + **Scenario outside the disclosure boundary** (HIGH) + """) + assert not QaDocOrchestrator._contains_extractable_test_cases(""" + + **Scenario between reversed markers** (HIGH) + + """) + + def test_moves_an_overbroad_end_marker_before_later_numbered_sections(self): + documentation = """ + ### 1. Change Summary + Checkout changed. + + + ### 3. Test Scenarios + ### Checkout + **Checkout succeeds** (HIGH) + - **Expected Result:** Confirmation appears + + ### 4. Edge Cases and Negative Testing + - Try an expired card. + + ### 5. Regression Risks + - Existing card payments. + + ### 6. Environment and Setup Notes + - Use the QA environment. + + """ + + normalized = QaDocOrchestrator._normalize_test_case_markers(documentation) + + assert normalized.index("") < normalized.index( + "### 4. Edge Cases and Negative Testing" + ) + assert normalized.index("") > normalized.index( + "**Checkout succeeds** (HIGH)" + ) + assert "### 5. Regression Risks" in normalized + assert "### 6. Environment and Setup Notes" in normalized + + @pytest.mark.asyncio(loop_scope="function") + async def test_repairs_a_custom_document_that_omits_test_cases(self): + response = MagicMock(content=""" + + ### Test Scenarios + **Checkout succeeds** (HIGH) + - **Expected Result:** Confirmation appears + + """) + llm = MagicMock() + llm.ainvoke = AsyncMock(return_value=response) + orchestrator = QaDocOrchestrator(llm=llm) + placeholders = { + "output_language": "English", + "pr_number": "12", + "project_name": "Project", + "pr_title": "Checkout", + "task_key": "QA-12", + "task_summary": "Test checkout", + "source_branch": "feature", + "target_branch": "main", + "task_context": "", + "analysis_summary": "Checkout behavior changed", + "diff": "+ changed behavior", + } + + repaired = await orchestrator._ensure_test_cases("# Custom QA summary", placeholders) + + assert repaired.startswith("# Custom QA summary") + assert "" in repaired + assert "**Checkout succeeds** (HIGH)" in repaired + + @pytest.mark.asyncio(loop_scope="function") + async def test_rejects_a_repair_without_a_structured_scenario(self): + llm = MagicMock() + llm.ainvoke = AsyncMock(return_value=MagicMock(content="General testing notes")) + orchestrator = QaDocOrchestrator(llm=llm) + placeholders = { + "output_language": "English", + "pr_number": "12", + "project_name": "Project", + "pr_title": "Checkout", + "task_key": "QA-12", + "task_summary": "Test checkout", + "source_branch": "feature", + "target_branch": "main", + "task_context": "", + "analysis_summary": "Checkout behavior changed", + "diff": "+ changed behavior", + } + + with pytest.raises(ValueError, match="no structured scenarios"): + await orchestrator._ensure_test_cases("# Custom QA summary", placeholders) + + # ── QaDocOrchestrator._build_placeholders ──────────────────────── class TestBuildPlaceholders: @@ -250,8 +367,57 @@ def test_defaults_when_empty(self): ) assert result["project_name"] == "Unknown" assert result["pr_number"] == "N/A" + assert result["pr_title"] == "QA documentation" assert result["diff"] == "No diff available." + def test_title_falls_back_to_task_summary_before_task_key(self): + orch = QaDocOrchestrator(llm=MagicMock()) + result = orch._build_placeholders( + project_name="TestProject", + pr_number=42, + issues_found=0, + files_analyzed=0, + pr_metadata={"prTitle": "N/A"}, + task_context_dict={ + "task_key": "JIRA-123", + "task_summary": "Support split shipments", + }, + task_context_block="", + diff="d", + ) + + assert result["pr_title"] == "Support split shipments" + + def test_title_falls_back_to_pr_number_without_task_metadata(self): + orch = QaDocOrchestrator(llm=MagicMock()) + result = orch._build_placeholders( + project_name=None, + pr_number=42, + issues_found=0, + files_analyzed=0, + pr_metadata={"prTitle": " "}, + task_context_dict=None, + task_context_block="", + diff="d", + ) + + assert result["pr_title"] == "PR #42" + + def test_replaces_na_in_the_rendered_guide_title(self): + documentation = """# QA Testing Guide — N/A + +## 1. What Changed +Checkout behavior changed. +""" + + normalized = QaDocOrchestrator._normalize_document_title( + documentation, + "Support split shipments", + ) + + assert normalized.startswith("# QA Testing Guide — Support split shipments") + assert "— N/A" not in normalized + def test_custom_language(self): orch = QaDocOrchestrator(llm=MagicMock()) result = orch._build_placeholders(