DslApiController.java
package com.taxonomy.versioning.controller;
import com.taxonomy.dsl.diff.DiffSummary;
import com.taxonomy.dsl.diff.ModelDiff;
import com.taxonomy.dsl.diff.SemanticDiffDescriber;
import com.taxonomy.dsl.export.DslMaterializeService;
import com.taxonomy.dsl.mapper.AstToModelMapper;
import com.taxonomy.dsl.mapper.ModelToAstMapper;
import com.taxonomy.dsl.model.CanonicalArchitectureModel;
import com.taxonomy.dsl.parser.TaxDslParser;
import com.taxonomy.dsl.serializer.TaxDslSerializer;
import com.taxonomy.dsl.storage.DslBranch;
import com.taxonomy.dsl.storage.DslCommit;
import com.taxonomy.dsl.validation.DslValidationResult;
import com.taxonomy.dsl.validation.DslValidator;
import com.taxonomy.dto.ViewContext;
import com.taxonomy.dto.VersionedSearchResult;
import com.taxonomy.architecture.model.ArchitectureDslDocument;
import com.taxonomy.model.HypothesisStatus;
import com.taxonomy.relations.model.RelationHypothesis;
import com.taxonomy.versioning.service.ConflictDetectionService;
import com.taxonomy.versioning.service.DslOperationsFacade;
import com.taxonomy.versioning.service.HypothesisService;
import com.taxonomy.workspace.service.RepositoryStateGuard;
import com.taxonomy.workspace.service.WorkspaceResolver;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.util.*;
/**
* REST API for the Architecture DSL subsystem.
*
* <p>Endpoints cover DSL parsing, validation, export, materialization,
* versioning (commit/history/diff/branches), and hypothesis management.
*
* <p>Versioning is backed by a JGit DFS repository
* which stores DSL documents as Git objects (blobs → trees → commits)
* in the HSQLDB database via the {@code sandbox-jgit-storage-hibernate}
* pattern. JGit is the <b>single source of truth</b> for versioned DSL content.
*/
@RestController
@RequestMapping("/api/dsl")
@Tag(name = "Architecture DSL")
public class DslApiController {
private static final Logger log = LoggerFactory.getLogger(DslApiController.class);
private final DslOperationsFacade dslOps;
private final HypothesisService hypothesisService;
private final WorkspaceResolver workspaceResolver;
private final TaxDslParser parser = new TaxDslParser();
private final TaxDslSerializer serializer = new TaxDslSerializer();
private final AstToModelMapper astMapper = new AstToModelMapper();
private final ModelToAstMapper modelToAstMapper = new ModelToAstMapper();
private final DslValidator validator = new DslValidator();
public DslApiController(DslOperationsFacade dslOps,
HypothesisService hypothesisService,
WorkspaceResolver workspaceResolver) {
this.dslOps = dslOps;
this.hypothesisService = hypothesisService;
this.workspaceResolver = workspaceResolver;
}
// ── Export & current state ────────────────────────────────────────
@GetMapping("/export")
@Operation(summary = "Export current architecture as DSL text")
public ResponseEntity<String> exportCurrentArchitecture(
@RequestParam(defaultValue = "default") String namespace) {
String dsl = dslOps.exportAll(namespace);
return ResponseEntity.ok()
.contentType(MediaType.TEXT_PLAIN)
.body(dsl);
}
@GetMapping("/current")
@Operation(summary = "Get current architecture state as structured JSON")
public ResponseEntity<Map<String, Object>> getCurrentArchitecture() {
CanonicalArchitectureModel model = dslOps.buildCanonicalModel();
String username = workspaceResolver.resolveCurrentUsername();
String branch = dslOps.resolveWorkspaceBranch(username);
Map<String, Object> result = new LinkedHashMap<>();
result.put("elements", model.getElements());
result.put("relations", model.getRelations());
result.put("requirements", model.getRequirements());
result.put("mappings", model.getMappings());
result.put("views", model.getViews());
result.put("evidence", model.getEvidence());
result.put("viewContext", dslOps.getViewContext(branch));
return ResponseEntity.ok(result);
}
// ── Parse & validate ─────────────────────────────────────────────
@PostMapping("/parse")
@Operation(summary = "Parse DSL text and return the canonical model as JSON")
public ResponseEntity<Map<String, Object>> parseDsl(@RequestBody(required = false) String dslText) {
var doc = parser.parse(dslText != null ? dslText : "");
CanonicalArchitectureModel model = astMapper.map(doc);
DslValidationResult validation = validator.validate(model);
Map<String, Object> result = new LinkedHashMap<>();
result.put("valid", validation.isValid());
result.put("errors", validation.getErrors());
result.put("warnings", validation.getWarnings());
result.put("elements", model.getElements().size());
result.put("relations", model.getRelations().size());
result.put("requirements", model.getRequirements().size());
result.put("mappings", model.getMappings().size());
result.put("views", model.getViews().size());
result.put("evidence", model.getEvidence().size());
return ResponseEntity.ok(result);
}
@PostMapping("/validate")
@Operation(summary = "Validate DSL text and return errors/warnings")
public ResponseEntity<Map<String, Object>> validateDsl(@RequestBody(required = false) String dslText) {
var doc = parser.parse(dslText != null ? dslText : "");
CanonicalArchitectureModel model = astMapper.map(doc);
DslValidationResult validation = validator.validate(model);
Map<String, Object> result = new LinkedHashMap<>();
result.put("valid", validation.isValid());
result.put("errors", validation.getErrors());
result.put("warnings", validation.getWarnings());
return ResponseEntity.ok(result);
}
@PostMapping("/format")
@Operation(summary = "Format DSL text into canonical form",
description = "Parses the input DSL, maps it through the canonical model, and re-serializes " +
"it using TaxDslSerializer to produce deterministic, Git-diff-friendly output.")
public ResponseEntity<String> formatDsl(@RequestBody(required = false) String dslText) {
String text = dslText != null ? dslText : "";
var doc = parser.parse(text);
CanonicalArchitectureModel model = astMapper.map(doc);
var reformatted = modelToAstMapper.toDocument(model, "default");
String formatted = serializer.serialize(reformatted);
return ResponseEntity.ok()
.contentType(MediaType.TEXT_PLAIN)
.body(formatted);
}
// ── Materialization ──────────────────────────────────────────────
@PostMapping("/materialize")
@Operation(summary = "Parse, validate, and materialize DSL into the database",
description = "Relations with status=accepted become TaxonomyRelation entities. " +
"Relations with status=proposed/provisional become RelationHypothesis entities.")
public ResponseEntity<Map<String, Object>> materializeDsl(
@RequestBody String dslText,
@RequestParam(required = false) String path,
@RequestParam(required = false) String branch,
@RequestParam(required = false) String commitId) {
DslMaterializeService.MaterializeResult matResult =
dslOps.materialize(dslText, path, branch, commitId);
Map<String, Object> result = new LinkedHashMap<>();
result.put("valid", matResult.valid());
result.put("errors", matResult.errors());
result.put("warnings", matResult.warnings());
result.put("relationsCreated", matResult.relationsCreated());
result.put("hypothesesCreated", matResult.hypothesesCreated());
result.put("documentId", matResult.documentId());
String effectiveBranch = branch != null ? branch : "draft";
result.put("viewContext", dslOps.getViewContext(effectiveBranch));
if (!matResult.valid()) {
return ResponseEntity.badRequest().body(result);
}
return ResponseEntity.ok(result);
}
@PostMapping("/materialize-incremental")
@Operation(summary = "Incrementally materialize only the delta between two DSL versions",
description = "Computes a diff between the before and after documents, then only " +
"creates/updates the changed relations. More efficient than full materialization.")
public ResponseEntity<Map<String, Object>> materializeIncremental(
@RequestParam(required = false) Long beforeDocId,
@RequestParam Long afterDocId) {
try {
DslMaterializeService.MaterializeResult matResult =
dslOps.materializeIncremental(beforeDocId, afterDocId);
// Derive branch from the after document when available
String branch = "draft";
var afterDoc = dslOps.findDocumentById(afterDocId);
if (afterDoc.isPresent() && afterDoc.get().getBranch() != null) {
branch = afterDoc.get().getBranch();
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("valid", matResult.valid());
result.put("warnings", matResult.warnings());
result.put("relationsCreated", matResult.relationsCreated());
result.put("hypothesesCreated", matResult.hypothesesCreated());
result.put("documentId", matResult.documentId());
result.put("viewContext", dslOps.getViewContext(branch));
return ResponseEntity.ok(result);
} catch (IllegalArgumentException e) {
Map<String, Object> error = new LinkedHashMap<>();
error.put("error", e.getMessage());
return ResponseEntity.badRequest().body(error);
}
}
// ── Commit & versioning ──────────────────────────────────────────
@PostMapping("/commit")
@Operation(summary = "Commit DSL text as a versioned document",
description = "Stores the DSL text as a new commit in the JGit repository " +
"(database-backed via HibernateRepository). JGit is the single " +
"source of truth. Does not materialize — use POST /api/dsl/materialize " +
"or POST /api/dsl/materialize-incremental for that.")
public ResponseEntity<Map<String, Object>> commitDsl(
@RequestBody String dslText,
@RequestParam(defaultValue = "draft") String branch,
@RequestParam(required = false) String message,
Authentication authentication) {
// Validate DSL text before committing
var doc = parser.parse(dslText != null ? dslText : "");
CanonicalArchitectureModel model = astMapper.map(doc);
DslValidationResult validation = validator.validate(model);
if (!validation.isValid()) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("valid", false);
result.put("errors", validation.getErrors());
result.put("warnings", validation.getWarnings());
return ResponseEntity.badRequest().body(result);
}
// Commit to JGit repository — the single source of truth
// All Git objects are stored in the database (git_packs table)
// via HibernateRepository/HibernateObjDatabase
String author = authentication != null ? authentication.getName() : "system";
String gitCommitId;
try {
gitCommitId = dslOps.commitDsl(branch, dslText,
author,
message != null ? message : "DSL commit");
} catch (IOException e) {
log.error("JGit commit failed", e);
Map<String, Object> error = new LinkedHashMap<>();
error.put("valid", false);
error.put("errors", List.of("Git commit failed: " + e.getMessage()));
return ResponseEntity.internalServerError().body(error);
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("commitId", gitCommitId);
result.put("branch", branch);
result.put("author", author);
result.put("message", message);
result.put("valid", true);
result.put("warnings", validation.getWarnings());
result.put("databaseBacked", dslOps.isDatabaseBacked());
result.put("viewContext", dslOps.getViewContext(branch));
return ResponseEntity.ok(result);
}
@GetMapping("/history")
@Operation(summary = "Get commit history for a branch",
description = "Returns all DSL commits on the specified branch from the JGit " +
"repository (database-backed), newest first.")
public ResponseEntity<Map<String, Object>> getHistory(
@RequestParam(defaultValue = "draft") String branch) {
try {
List<DslCommit> gitHistory = dslOps.getDslHistory(branch);
List<Map<String, Object>> history = new ArrayList<>();
for (DslCommit c : gitHistory) {
Map<String, Object> entry = new LinkedHashMap<>();
entry.put("commitId", c.commitId());
entry.put("branch", branch);
entry.put("author", c.author());
entry.put("message", c.message());
entry.put("timestamp", c.timestamp());
// Resolve documentId from the materialized document (if it exists)
entry.put("documentId", dslOps.findDocumentIdByCommitId(c.commitId()).orElse(null));
history.add(entry);
}
ViewContext viewContext = dslOps.getViewContext(branch);
Map<String, Object> result = new LinkedHashMap<>();
result.put("currentBranch", branch);
result.put("headCommit", viewContext.basedOnCommit());
result.put("commits", history);
result.put("viewContext", viewContext);
return ResponseEntity.ok(result);
} catch (Exception e) {
log.error("Failed to read history for branch '{}'", branch, e);
Map<String, Object> errorResult = new LinkedHashMap<>();
errorResult.put("errorCode", "HISTORY_LOAD_FAILED");
errorResult.put("commits", List.of());
errorResult.put("currentBranch", branch);
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(errorResult);
}
}
@GetMapping("/diff/{beforeId}/{afterId}")
@Operation(summary = "Compute semantic diff between two DSL commits",
description = "Returns the added, removed, and changed elements and relations " +
"between two Git commit SHAs.")
public ResponseEntity<?> diffDocuments(
@PathVariable String beforeId,
@PathVariable String afterId) {
try {
ModelDiff diff = dslOps.diffBetween(beforeId, afterId);
Map<String, Object> result = new LinkedHashMap<>();
result.put("totalChanges", diff.totalChanges());
result.put("isEmpty", diff.isEmpty());
result.put("addedElements", diff.addedElements().size());
result.put("removedElements", diff.removedElements().size());
result.put("changedElements", diff.changedElements().size());
result.put("addedRelations", diff.addedRelations().size());
result.put("removedRelations", diff.removedRelations().size());
result.put("changedRelations", diff.changedRelations().size());
// Include structural details
Map<String, Object> details = new LinkedHashMap<>();
details.put("addedElements", diff.addedElements());
details.put("removedElements", diff.removedElements());
details.put("changedElements", diff.changedElements());
details.put("addedRelations", diff.addedRelations());
details.put("removedRelations", diff.removedRelations());
details.put("changedRelations", diff.changedRelations());
result.put("details", details);
// Include semantic changes for better reviewability
SemanticDiffDescriber describer = new SemanticDiffDescriber();
result.put("semanticChanges", describer.describe(diff));
result.put("semanticSummary", describer.summarize(diff));
return ResponseEntity.ok(result);
} catch (Exception e) {
Map<String, Object> error = new LinkedHashMap<>();
error.put("error", e.getMessage());
return ResponseEntity.badRequest().body(error);
}
}
@GetMapping("/diff/semantic/{beforeId}/{afterId}")
@Operation(summary = "Compute semantic diff between two DSL commits",
description = "Returns human-readable semantic change descriptions " +
"(e.g. 'Title changed', 'Relation added') together with statistics " +
"and before/after values—designed for reviews and change documentation.")
public ResponseEntity<?> semanticDiff(
@PathVariable String beforeId,
@PathVariable String afterId) {
try {
ModelDiff diff = dslOps.diffBetween(beforeId, afterId);
DiffSummary summary = DiffSummary.fromDiff(diff);
return ResponseEntity.ok(summary);
} catch (Exception e) {
Map<String, Object> error = new LinkedHashMap<>();
error.put("error", e.getMessage());
return ResponseEntity.badRequest().body(error);
}
}
@GetMapping("/diff/text/{beforeId}/{afterId}")
@Operation(summary = "Compute JGit-native text diff between two commits",
description = "Returns a unified diff patch produced by JGit DiffFormatter.")
public ResponseEntity<?> textDiff(
@PathVariable String beforeId,
@PathVariable String afterId) {
try {
String diff = dslOps.textDiff(beforeId, afterId);
return ResponseEntity.ok()
.contentType(MediaType.TEXT_PLAIN)
.body(diff);
} catch (Exception e) {
Map<String, Object> error = new LinkedHashMap<>();
error.put("error", e.getMessage());
return ResponseEntity.badRequest().body(error);
}
}
// ── Branches ─────────────────────────────────────────────────────
@GetMapping("/branches")
@Operation(summary = "List all branches",
description = "Returns branches from the JGit repository (database-backed).")
public ResponseEntity<List<Map<String, Object>>> listBranches() {
List<Map<String, Object>> branches = new ArrayList<>();
try {
for (DslBranch gb : dslOps.listBranches()) {
Map<String, Object> branch = new LinkedHashMap<>();
branch.put("name", gb.name());
branch.put("headCommitId", gb.headCommitId());
branch.put("created", gb.created());
branches.add(branch);
}
} catch (IOException e) {
log.error("Failed to list branches", e);
return ResponseEntity.internalServerError().build();
}
return ResponseEntity.ok(branches);
}
@PostMapping("/branches")
@Operation(summary = "Create a new branch by forking from an existing branch",
description = "Creates a new Git branch pointing at the HEAD of the source branch.")
public ResponseEntity<Map<String, Object>> createBranch(
@RequestParam String name,
@RequestParam(required = false, defaultValue = "draft") String fromBranch) {
String gitHeadId;
try {
gitHeadId = dslOps.createBranch(name, fromBranch);
} catch (IOException e) {
log.error("Branch creation failed", e);
Map<String, Object> error = new LinkedHashMap<>();
error.put("error", "Branch creation failed: " + e.getMessage());
return ResponseEntity.internalServerError().body(error);
}
if (gitHeadId == null) {
Map<String, Object> error = new LinkedHashMap<>();
error.put("error", "Source branch '" + fromBranch + "' not found");
return ResponseEntity.badRequest().body(error);
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("branch", name);
result.put("commitId", gitHeadId);
result.put("forkedFrom", fromBranch);
return ResponseEntity.ok(result);
}
// ── Cherry-pick & merge ─────────────────────────────────────────
@PostMapping("/cherry-pick")
@Operation(summary = "Cherry-pick a commit onto a target branch",
description = "Applies the changes from a specific commit to the HEAD of the target branch.")
public ResponseEntity<Map<String, Object>> cherryPick(
@RequestParam String commitId,
@RequestParam(defaultValue = "review") String targetBranch) {
try {
String newCommitId = dslOps.cherryPick(commitId, targetBranch);
if (newCommitId == null) {
Map<String, Object> error = new LinkedHashMap<>();
error.put("error", "Cherry-pick failed (conflict or invalid commit)");
return ResponseEntity.badRequest().body(error);
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("commitId", newCommitId);
result.put("targetBranch", targetBranch);
result.put("cherryPickedFrom", commitId);
return ResponseEntity.ok(result);
} catch (org.eclipse.jgit.errors.MissingObjectException e) {
Map<String, Object> error = new LinkedHashMap<>();
error.put("error", "Cherry-pick failed: commit not found — " + e.getMessage());
return ResponseEntity.badRequest().body(error);
} catch (IOException e) {
Map<String, Object> error = new LinkedHashMap<>();
error.put("error", "Cherry-pick failed: " + e.getMessage());
return ResponseEntity.internalServerError().body(error);
}
}
@PostMapping("/merge")
@Operation(summary = "Merge one branch into another",
description = "Performs a three-way merge of the source branch into the target branch.")
public ResponseEntity<Map<String, Object>> merge(
@RequestParam String fromBranch,
@RequestParam(defaultValue = "accepted") String intoBranch) {
try {
String mergeCommitId = dslOps.merge(fromBranch, intoBranch);
if (mergeCommitId == null) {
Map<String, Object> error = new LinkedHashMap<>();
error.put("error", "Merge failed (conflict or branches not found)");
return ResponseEntity.badRequest().body(error);
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("commitId", mergeCommitId);
result.put("fromBranch", fromBranch);
result.put("intoBranch", intoBranch);
return ResponseEntity.ok(result);
} catch (IOException e) {
Map<String, Object> error = new LinkedHashMap<>();
error.put("error", "Merge failed: " + e.getMessage());
return ResponseEntity.internalServerError().body(error);
}
}
// ── Revert, undo & restore ─────────────────────────────────────
@PostMapping("/revert")
@Operation(summary = "Revert a specific commit on a branch",
description = "Creates a new commit that undoes the changes of the specified commit. " +
"Uses three-way merge to cleanly reverse the commit.")
public ResponseEntity<Map<String, Object>> revert(
@RequestParam String commitId,
@RequestParam(defaultValue = "draft") String branch) {
try {
String newCommitId = dslOps.revert(commitId, branch);
if (newCommitId == null) {
Map<String, Object> error = new LinkedHashMap<>();
error.put("error", "Revert failed (conflict, initial commit, or branch not found)");
return ResponseEntity.badRequest().body(error);
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("commitId", newCommitId);
result.put("branch", branch);
result.put("revertedCommit", commitId);
return ResponseEntity.ok(result);
} catch (org.eclipse.jgit.errors.MissingObjectException e) {
Map<String, Object> error = new LinkedHashMap<>();
error.put("error", "Revert failed: commit not found — " + e.getMessage());
return ResponseEntity.badRequest().body(error);
} catch (IOException e) {
Map<String, Object> error = new LinkedHashMap<>();
error.put("error", "Revert failed: " + e.getMessage());
return ResponseEntity.internalServerError().body(error);
}
}
@PostMapping("/undo")
@Operation(summary = "Undo the last commit on a branch",
description = "Resets the branch to its parent commit, effectively removing the last " +
"commit from the branch history. Cannot undo the initial commit.")
public ResponseEntity<Map<String, Object>> undoLast(
@RequestParam(defaultValue = "draft") String branch) {
try {
String newHeadId = dslOps.undoLast(branch);
if (newHeadId == null) {
Map<String, Object> error = new LinkedHashMap<>();
error.put("error", "Undo failed (branch not found or only initial commit)");
return ResponseEntity.badRequest().body(error);
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("commitId", newHeadId);
result.put("branch", branch);
return ResponseEntity.ok(result);
} catch (IOException e) {
Map<String, Object> error = new LinkedHashMap<>();
error.put("error", "Undo failed: " + e.getMessage());
return ResponseEntity.internalServerError().body(error);
}
}
@PostMapping("/restore")
@Operation(summary = "Restore DSL content from a specific commit",
description = "Creates a new commit on the branch with the DSL content from an older " +
"commit. This is a forward-moving 'restore to version' operation.")
public ResponseEntity<Map<String, Object>> restore(
@RequestParam String commitId,
@RequestParam(defaultValue = "draft") String branch) {
try {
String newCommitId = dslOps.restore(commitId, branch);
if (newCommitId == null) {
Map<String, Object> error = new LinkedHashMap<>();
error.put("error", "Restore failed: source commit not found or has no DSL content");
return ResponseEntity.badRequest().body(error);
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("commitId", newCommitId);
result.put("branch", branch);
result.put("restoredFrom", commitId);
return ResponseEntity.ok(result);
} catch (Exception e) {
Map<String, Object> error = new LinkedHashMap<>();
error.put("error", "Restore failed: " + e.getMessage());
return ResponseEntity.internalServerError().body(error);
}
}
// ── Merge/Cherry-pick preview ───────────────────────────────────
@GetMapping("/merge/preview")
@Operation(summary = "Preview a merge: would it conflict?",
description = "Dry-run merge check. Returns whether the merge would succeed, " +
"whether it's a fast-forward, or whether conflicts would occur.")
public ResponseEntity<ConflictDetectionService.MergePreview> previewMerge(
@RequestParam String from,
@RequestParam String into) {
return ResponseEntity.ok(dslOps.previewMerge(from, into));
}
@GetMapping("/cherry-pick/preview")
@Operation(summary = "Preview a cherry-pick: would it conflict?",
description = "Dry-run cherry-pick check. Returns whether the cherry-pick would succeed " +
"or whether conflicts would occur.")
public ResponseEntity<ConflictDetectionService.CherryPickPreview> previewCherryPick(
@RequestParam String commitId,
@RequestParam(defaultValue = "review") String targetBranch) {
return ResponseEntity.ok(dslOps.previewCherryPick(commitId, targetBranch));
}
// ── Conflict details & resolution ───────────────────────────────
@GetMapping("/merge/conflicts")
@Operation(summary = "Get merge conflict details",
description = "Returns the DSL content from both sides of a conflicting merge, " +
"enabling the UI to display a side-by-side resolution view.")
public ResponseEntity<?> getMergeConflictDetails(
@RequestParam String from,
@RequestParam String into) {
var details = dslOps.getMergeConflictDetails(from, into);
if (details == null) {
return ResponseEntity.ok(Map.of("conflict", false, "message", "No conflict detected"));
}
return ResponseEntity.ok(details);
}
@PostMapping("/merge/resolve")
@Operation(summary = "Merge with manually resolved content",
description = "Commits the user-provided resolved DSL content to the target branch. " +
"This resolves a merge conflict by accepting the user's manually edited content " +
"as a new commit on the target branch.")
public ResponseEntity<Map<String, Object>> mergeResolve(
@RequestParam String fromBranch,
@RequestParam String intoBranch,
@RequestBody String resolvedContent) {
try {
// Commit the resolved content to the target branch
String username = dslOps.resolveCurrentUsername();
String commitId = dslOps.commitDsl(intoBranch, resolvedContent,
username, "Resolve merge conflict: " + fromBranch + " → " + intoBranch);
Map<String, Object> result = new LinkedHashMap<>();
result.put("commitId", commitId);
result.put("fromBranch", fromBranch);
result.put("intoBranch", intoBranch);
result.put("resolution", "manual");
return ResponseEntity.ok(result);
} catch (Exception e) {
log.error("Merge resolution failed", e);
Map<String, Object> error = new LinkedHashMap<>();
error.put("error", "Merge resolution failed: " + e.getMessage());
return ResponseEntity.internalServerError().body(error);
}
}
@GetMapping("/cherry-pick/conflicts")
@Operation(summary = "Get cherry-pick conflict details",
description = "Returns the DSL content from both sides of a conflicting cherry-pick.")
public ResponseEntity<?> getCherryPickConflictDetails(
@RequestParam String commitId,
@RequestParam(defaultValue = "review") String targetBranch) {
var details = dslOps.getCherryPickConflictDetails(commitId, targetBranch);
if (details == null) {
return ResponseEntity.ok(Map.of("conflict", false, "message", "No conflict detected"));
}
return ResponseEntity.ok(details);
}
@PostMapping("/cherry-pick/resolve")
@Operation(summary = "Cherry-pick with manually resolved content",
description = "Commits the user-provided resolved DSL content to the target branch, " +
"resolving a cherry-pick conflict.")
public ResponseEntity<Map<String, Object>> cherryPickResolve(
@RequestParam String commitId,
@RequestParam String targetBranch,
@RequestBody String resolvedContent,
Authentication authentication) {
try {
String username = authentication != null ? authentication.getName() : "system";
String newCommitId = dslOps.commitDsl(targetBranch, resolvedContent,
username, "Resolve cherry-pick conflict: " + commitId.substring(0, Math.min(7, commitId.length())));
Map<String, Object> result = new LinkedHashMap<>();
result.put("commitId", newCommitId);
result.put("targetBranch", targetBranch);
result.put("cherryPickedFrom", commitId);
result.put("resolution", "manual");
return ResponseEntity.ok(result);
} catch (Exception e) {
log.error("Cherry-pick resolution failed", e);
Map<String, Object> error = new LinkedHashMap<>();
error.put("error", "Cherry-pick resolution failed: " + e.getMessage());
return ResponseEntity.internalServerError().body(error);
}
}
// ── Branch deletion ─────────────────────────────────────────────
@DeleteMapping("/branch")
@Operation(summary = "Delete a branch",
description = "Deletes a Git branch. Protected branches (draft, accepted, main) " +
"cannot be deleted.")
public ResponseEntity<Map<String, Object>> deleteBranch(@RequestParam String name) {
try {
boolean deleted = dslOps.deleteBranch(name);
if (!deleted) {
Map<String, Object> error = new LinkedHashMap<>();
error.put("error", "Branch '" + name + "' not found");
return ResponseEntity.status(404).body(error);
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("deleted", name);
result.put("success", true);
return ResponseEntity.ok(result);
} catch (IllegalArgumentException e) {
Map<String, Object> error = new LinkedHashMap<>();
error.put("error", e.getMessage());
return ResponseEntity.badRequest().body(error);
} catch (IOException e) {
log.error("Branch deletion failed", e);
Map<String, Object> error = new LinkedHashMap<>();
error.put("error", "Branch deletion failed: " + e.getMessage());
return ResponseEntity.internalServerError().body(error);
}
}
@GetMapping("/operation/check")
@Operation(summary = "Check whether a write operation is safe",
description = "Returns warnings (e.g. stale projection) and blocks (e.g. operation in progress) " +
"for a given write operation type.")
public ResponseEntity<RepositoryStateGuard.OperationCheck> checkOperation(
@RequestParam(defaultValue = "draft") String branch,
@RequestParam String operationType) {
return ResponseEntity.ok(dslOps.checkWriteOperation(branch, operationType));
}
// ── Git-backed read operations ──────────────────────────────────
@GetMapping("/git/head")
@Operation(summary = "Read DSL text from the HEAD of a Git branch",
description = "Reads the DSL text directly from the JGit repository.")
public ResponseEntity<Map<String, Object>> getGitHead(
@RequestParam(defaultValue = "draft") String branch) {
try {
String dslText = dslOps.getDslAtHead(branch);
if (dslText == null) {
Map<String, Object> error = new LinkedHashMap<>();
error.put("error", "Branch '" + branch + "' not found or empty");
return ResponseEntity.notFound().build();
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("branch", branch);
result.put("dslText", dslText);
result.put("length", dslText.length());
result.put("viewContext", dslOps.getViewContext(branch));
return ResponseEntity.ok(result);
} catch (IOException e) {
Map<String, Object> error = new LinkedHashMap<>();
error.put("error", "Git read failed: " + e.getMessage());
return ResponseEntity.internalServerError().body(error);
}
}
@GetMapping("/git/commit/{commitId}")
@Operation(summary = "Read DSL text from a specific Git commit",
description = "Reads the architecture.taxdsl file from the given commit SHA.")
public ResponseEntity<Map<String, Object>> getGitCommit(@PathVariable String commitId) {
try {
String dslText = dslOps.getDslAtCommit(commitId);
if (dslText == null) {
return ResponseEntity.notFound().build();
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("commitId", commitId);
result.put("dslText", dslText);
result.put("length", dslText.length());
return ResponseEntity.ok(result);
} catch (Exception e) {
Map<String, Object> error = new LinkedHashMap<>();
error.put("error", "Git read failed: " + e.getMessage());
return ResponseEntity.badRequest().body(error);
}
}
// ── Hypothesis management ────────────────────────────────────────
@GetMapping("/hypotheses")
@Operation(summary = "List relation hypotheses, optionally filtered by status")
public ResponseEntity<List<RelationHypothesis>> listHypotheses(
@RequestParam(required = false) HypothesisStatus status) {
List<RelationHypothesis> result;
if (status != null) {
result = hypothesisService.findByStatus(status);
} else {
result = hypothesisService.findAll();
}
return ResponseEntity.ok(result);
}
@PostMapping("/hypotheses/{id}/accept")
@Operation(summary = "Accept a relation hypothesis",
description = "Promotes the hypothesis to an accepted TaxonomyRelation in the knowledge graph.")
public ResponseEntity<Map<String, Object>> acceptHypothesis(@PathVariable Long id) {
try {
RelationHypothesis accepted = hypothesisService.accept(id);
Map<String, Object> result = new LinkedHashMap<>();
result.put("id", accepted.getId());
result.put("status", accepted.getStatus().name());
result.put("sourceNodeId", accepted.getSourceNodeId());
result.put("targetNodeId", accepted.getTargetNodeId());
result.put("relationType", accepted.getRelationType().name());
return ResponseEntity.ok(result);
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
} catch (IllegalStateException e) {
Map<String, Object> error = new LinkedHashMap<>();
error.put("error", e.getMessage());
return ResponseEntity.badRequest().body(error);
}
}
@PostMapping("/hypotheses/{id}/reject")
@Operation(summary = "Reject a relation hypothesis")
public ResponseEntity<Map<String, Object>> rejectHypothesis(@PathVariable Long id) {
try {
RelationHypothesis rejected = hypothesisService.reject(id);
Map<String, Object> result = new LinkedHashMap<>();
result.put("id", rejected.getId());
result.put("status", rejected.getStatus().name());
return ResponseEntity.ok(result);
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
} catch (IllegalStateException e) {
Map<String, Object> error = new LinkedHashMap<>();
error.put("error", e.getMessage());
return ResponseEntity.badRequest().body(error);
}
}
@PostMapping("/hypotheses/{id}/apply-session")
@Operation(summary = "Mark hypothesis as applied for current analysis session only",
description = "The relationship is used in the current Architecture View and exports " +
"but is not permanently persisted as a TaxonomyRelation.")
public ResponseEntity<Map<String, Object>> applyHypothesisForSession(@PathVariable Long id) {
try {
RelationHypothesis hypothesis = hypothesisService.applyForSession(id);
Map<String, Object> result = new LinkedHashMap<>();
result.put("id", hypothesis.getId());
result.put("appliedInCurrentAnalysis", hypothesis.isAppliedInCurrentAnalysis());
result.put("status", hypothesis.getStatus().name());
return ResponseEntity.ok(result);
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
}
}
@GetMapping("/hypotheses/{id}/evidence")
@Operation(summary = "Get evidence records for a hypothesis")
public ResponseEntity<?> getHypothesisEvidence(@PathVariable Long id) {
try {
return ResponseEntity.ok(hypothesisService.findEvidence(id));
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
}
}
// ── Documents ────────────────────────────────────────────────────
@GetMapping("/documents")
@Operation(summary = "List stored DSL documents")
public ResponseEntity<List<ArchitectureDslDocument>> listDocuments() {
return ResponseEntity.ok(dslOps.listDocuments());
}
// ── History Search ──────────────────────────────────────────────
@PostMapping("/history/index")
@Operation(summary = "Index commits on a branch for history search",
description = "Parses and tokenizes all unindexed commits on the given branch.")
public ResponseEntity<Map<String, Object>> indexHistory(
@RequestParam(defaultValue = "draft") String branch) {
int indexed = dslOps.indexBranch(branch);
Map<String, Object> result = new LinkedHashMap<>();
result.put("branch", branch);
result.put("indexed", indexed);
return ResponseEntity.ok(result);
}
@GetMapping("/history/search")
@Operation(summary = "Search architecture commit history",
description = "Full-text search across tokenized DSL changes, commit messages, " +
"and affected element/relation IDs using Hibernate Search (Lucene backend). " +
"Results are ranked by relevance score.")
public ResponseEntity<?> searchHistory(
@RequestParam String query,
@RequestParam(defaultValue = "50") int maxResults) {
return ResponseEntity.ok(dslOps.searchHistory(query, maxResults));
}
@GetMapping("/history/element/{elementId}")
@Operation(summary = "Find commits that affected a specific element",
description = "Searches affectedElementIds and tokenized DSL text for the given " +
"element ID using Hibernate Search.")
public ResponseEntity<?> findHistoryByElement(@PathVariable String elementId) {
return ResponseEntity.ok(dslOps.findByElement(elementId));
}
@GetMapping("/history/relation")
@Operation(summary = "Find commits that affected a specific relation",
description = "Searches affectedRelationIds and tokenized DSL text for the given " +
"relation key (e.g., 'CP-1023 REALIZES CR-1047') using Hibernate Search.")
public ResponseEntity<?> findHistoryByRelation(@RequestParam String key) {
return ResponseEntity.ok(dslOps.findByRelation(key));
}
@GetMapping("/history/element/{elementId}/aggregation")
@Operation(summary = "Get aggregated history for an element",
description = "Returns firstSeen, lastSeen, occurrence count, volatility, " +
"and recent commit messages for the given element ID.")
public ResponseEntity<?> elementHistoryAggregation(@PathVariable String elementId) {
var aggregation = dslOps.aggregateElementHistory(elementId);
if (aggregation == null) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("elementId", elementId);
result.put("message", "No history found for element " + elementId);
return ResponseEntity.ok(result);
}
return ResponseEntity.ok(aggregation);
}
// ── Versioned History Search (Phase 2) ──────────────────────────
@GetMapping("/history/search-versioned")
@Operation(summary = "Search architecture history with version context",
description = "Full-text search returning results enriched with version metadata: " +
"lineage info, recency, and suggested context actions.")
public ResponseEntity<List<VersionedSearchResult>> searchVersioned(
@RequestParam String query,
@RequestParam(defaultValue = "draft") String currentBranch,
@RequestParam(defaultValue = "50") int maxResults) {
var hits = dslOps.searchHistory(query, maxResults);
if (hits.isEmpty()) {
return ResponseEntity.ok(List.of());
}
String headCommit;
try {
headCommit = dslOps.getHeadCommit(currentBranch);
} catch (IOException e) {
headCommit = null;
}
// Find the latest overall and latest on current branch
var latestOverall = hits.stream()
.filter(h -> h.getCommitTimestamp() != null)
.max(java.util.Comparator.comparing(h -> h.getCommitTimestamp()))
.orElse(null);
var latestOnBranch = hits.stream()
.filter(h -> currentBranch.equals(h.getBranch()) && h.getCommitTimestamp() != null)
.max(java.util.Comparator.comparing(h -> h.getCommitTimestamp()))
.orElse(null);
final String resolvedHead = headCommit;
List<VersionedSearchResult> results = hits.stream().map(hit -> {
boolean onLineage = currentBranch.equals(hit.getBranch());
boolean isLatestOnBranch = latestOnBranch != null
&& hit.getCommitId().equals(latestOnBranch.getCommitId());
boolean isLatestOverall = latestOverall != null
&& hit.getCommitId().equals(latestOverall.getCommitId());
List<String> actions = new java.util.ArrayList<>();
actions.add("OPEN_READ_ONLY");
if (!onLineage || !isLatestOnBranch) {
actions.add("SWITCH");
}
actions.add("CREATE_VARIANT");
actions.add("COMPARE");
return new VersionedSearchResult(
hit.getCommitId(),
hit.getBranch(),
hit.getCommitTimestamp(),
hit.getAffectedElementIds(),
hit.getMessage(),
0.0f,
onLineage,
isLatestOnBranch,
isLatestOverall,
actions
);
}).toList();
return ResponseEntity.ok(results);
}
}