Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
3a695fc
feat: Branch entity and controller
dkrizan Sep 17, 2025
35490cd
feat: handle branch in key/translation operations
dkrizan Sep 17, 2025
a07c64e
feat: copy branch data service
dkrizan Sep 19, 2025
a605a2c
feat: add branch filter to controllers related to keys/translations &…
dkrizan Sep 22, 2025
19d7c9e
feat: KeyController branching modifications
dkrizan Sep 23, 2025
63711fc
feat: create the default branch on Project creation
dkrizan Sep 23, 2025
6aca250
chore: fix KeysDistanceUnitTestData.kt
dkrizan Sep 23, 2025
ce0acc9
chore: Branch copy test improved - checks data consistency
dkrizan Sep 23, 2025
256bfb8
feat: Branch copy test improved - copies all key/translation-related …
dkrizan Sep 24, 2025
cbcc284
feat: create a key with either default branch (if it exists) or provi…
dkrizan Oct 1, 2025
9fd8472
feat: basic branch selector
dkrizan Oct 2, 2025
cca502a
fix: create a default branch when creating a project in service
dkrizan Oct 2, 2025
92345f8
feat: add branch parameters to Key create form
dkrizan Oct 3, 2025
2b44fda
fix: KeysTestData.kt - add default main branch
dkrizan Oct 3, 2025
eb6b8a4
feat: Branches page - list and create a new branch form
dkrizan Oct 3, 2025
bbb86ac
fix: it shows the correct branch in branch selector on the first app …
dkrizan Oct 6, 2025
2519a24
fix: do not return archived branches in branches endpoint
dkrizan Oct 6, 2025
4855ccd
feat: branch deletion (ux + async clean up service)
dkrizan Oct 7, 2025
1d921b6
fix: branch unique conditional index on project_id and name only on n…
dkrizan Oct 7, 2025
0bf2fbf
fix: ignore a deleted branch in queries + delete branch entity after …
dkrizan Oct 7, 2025
25561b5
chore: branch data copy service execution improvements
dkrizan Oct 7, 2025
69479d0
feat: create a default branch on the first load of branches
dkrizan Oct 7, 2025
f835851
fix: tests
dkrizan Oct 8, 2025
abc84c8
feat: branch revision number
dkrizan Oct 8, 2025
4e9f07a
feat: branch dry-run merge
dkrizan Oct 10, 2025
e19f501
feat: branch review conflict keys data endpoint
dkrizan Oct 30, 2025
4e488ce
feat: branch conflict resolving endpoint
dkrizan Oct 31, 2025
a401b9f
feat: branch merge applying
dkrizan Oct 31, 2025
e2f4d25
chore: apiSchema updated
dkrizan Nov 3, 2025
a6d835b
feat: endpoint to list branch merges
dkrizan Nov 3, 2025
7fdb09c
feat: create and delete branch merge
dkrizan Nov 4, 2025
e8e360a
feat: branch merge detail and list refactored
dkrizan Nov 7, 2025
fa6d22b
feat: global branch selector
dkrizan Nov 10, 2025
bf56aa2
feat: dry-run merge and merge apply refactored to use snapshot data a…
dkrizan Nov 10, 2025
6b4d490
feat: branch name rules and router for names containing slash character
dkrizan Nov 14, 2025
d8da5d1
feat: branch merge refresh
dkrizan Nov 18, 2025
377f571
chore: api schemas updated
dkrizan Nov 28, 2025
c2f1d3a
chore: ktlint
dkrizan Nov 28, 2025
fbb8c5d
feat: global branch selector holds state
dkrizan Nov 28, 2025
a12b153
feat: list adds, updates, deletes in merged detail + accept all buttons
dkrizan Nov 28, 2025
02692f0
feat: merge detail enhancement
dkrizan Dec 1, 2025
c539565
feat: add branch-based support to import
dkrizan Dec 4, 2025
bbe8ce3
feat: branch selector in Export view
dkrizan Dec 9, 2025
8951365
feat: merge enhancement - merge all non-conflict values, conflicted v…
dkrizan Dec 12, 2025
2c1dbdd
chore: snapshot stores translation labels test
dkrizan Dec 12, 2025
6f1ab86
chore: snapshot stores screenshots
dkrizan Dec 12, 2025
9be6cf8
chore: snapshot stores translations
dkrizan Dec 12, 2025
0db83b2
chore: branch merging service - test merging labels
dkrizan Dec 12, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -89,16 +89,17 @@ class V2ImportController(
userAccount = authenticationFacade.authenticatedUserEntity,
params = params,
)
return getImportAddFilesResultModel(errors, warnings)
return getImportAddFilesResultModel(errors, warnings, params)
}

private fun getImportAddFilesResultModel(
errors: List<ErrorResponseBody>,
warnings: List<ErrorResponseBody>,
params: ImportAddFilesParams,
): ImportAddFilesResultModel {
val result: PagedModel<ImportLanguageModel>? =
try {
this.getImportResult(PageRequest.of(0, 100))
this.getImportResult(PageRequest.of(0, 100), params.branch)
} catch (e: NotFoundException) {
null
}
Expand All @@ -115,9 +116,10 @@ class V2ImportController(
@Parameter(description = "Whether override or keep all translations with unresolved conflicts")
@RequestParam(defaultValue = "NO_FORCE")
forceMode: ForceMode,
@RequestParam branch: String? = null,
) {
val projectId = projectHolder.project.id
this.importService.import(projectId, authenticationFacade.authenticatedUser.id, forceMode)
this.importService.import(projectId, authenticationFacade.authenticatedUser.id, branch, forceMode)
}

@PutMapping("/apply-streaming", produces = [MediaType.APPLICATION_NDJSON_VALUE])
Expand All @@ -134,11 +136,12 @@ class V2ImportController(
@Parameter(description = "Whether override or keep all translations with unresolved conflicts")
@RequestParam(defaultValue = "NO_FORCE")
forceMode: ForceMode,
@RequestParam branch: String? = null,
): ResponseEntity<StreamingResponseBody> {
val projectId = projectHolder.project.id

return streamingImportProgressUtil.stream { writeStatus ->
this.importService.import(projectId, authenticationFacade.authenticatedUser.id, forceMode, writeStatus)
this.importService.import(projectId, authenticationFacade.authenticatedUser.id, branch, forceMode, writeStatus)
}
}

Expand All @@ -148,10 +151,11 @@ class V2ImportController(
@AllowApiAccess
fun getImportResult(
@ParameterObject pageable: Pageable,
@RequestParam branch: String? = null,
): PagedModel<ImportLanguageModel> {
val projectId = projectHolder.project.id
val userId = authenticationFacade.authenticatedUser.id
val languages = importService.getResult(projectId, userId, pageable)
val languages = importService.getResult(projectId, userId, branch, pageable)
return pagedLanguagesResourcesAssembler.toModel(languages, importLanguageModelAssembler)
}

Expand All @@ -160,8 +164,10 @@ class V2ImportController(
@RequiresProjectPermissions([Scope.TRANSLATIONS_VIEW])
@AllowApiAccess
@OpenApiOrderExtension(3)
fun cancelImport() {
this.importService.deleteImport(projectHolder.project.id, authenticationFacade.authenticatedUser.id)
fun cancelImport(
@RequestParam branch: String? = null,
) {
this.importService.deleteImport(projectHolder.project.id, authenticationFacade.authenticatedUser.id, branch)
}

@GetMapping("/all-namespaces")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,8 +147,10 @@ class KeyController(
@ParameterObject
@SortDefault("id")
pageable: Pageable,
@RequestParam
branch: String? = null,
): PagedModel<KeyModel> {
val data = keyService.getPaged(projectHolder.project.id, pageable)
val data = keyService.getPaged(projectHolder.project.id, branch, pageable)
return keyPagedResourcesAssembler.toModel(data, keyModelAssembler)
}

Expand All @@ -168,7 +170,8 @@ class KeyController(
key.checkInProject()
checkNamespaceFeature(dto.namespace)
keyService.edit(id, dto)
val view = KeyView(key.id, key.name, key?.namespace?.name, key.keyMeta?.description, key.keyMeta?.custom)
val view =
KeyView(key.id, key.name, key?.namespace?.name, key.keyMeta?.description, key.keyMeta?.custom, key.branch?.name)
return keyModelAssembler.toModel(view)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ class CreateOrUpdateTranslationsFacade(
@RequestBody @Valid
dto: SetTranslationsWithKeyDto,
): SetTranslationsResponseModel {
val key = keyService.find(projectHolder.projectEntity.id, dto.key, dto.namespace) ?: return create(dto)
val key = keyService.find(projectHolder.projectEntity.id, dto.key, dto.namespace, dto.branch) ?: return create(dto)
return setTranslations(dto, key)
}

Expand Down Expand Up @@ -74,7 +74,7 @@ class CreateOrUpdateTranslationsFacade(
dto: SetTranslationsWithKeyDto,
key: Key? = null,
): SetTranslationsResponseModel {
val keyNotNull = key ?: keyService.get(projectHolder.project.id, dto.key, dto.namespace)
val keyNotNull = key ?: keyService.get(projectHolder.project.id, dto.key, dto.namespace, dto.branch)
securityService.checkLanguageTranslatePermissionsByTag(
dto.translations.keys,
projectHolder.project.id,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,8 @@ When null, resulting file will be a flat key-value object.
)
@RequestParam(value = "filterTag", required = false)
filterTag: List<String>? = null,
@Parameter(description = "Branch name to return translations from")
branch: String? = null,
request: WebRequest,
Comment on lines +161 to 163
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Annotate branch as a query param to ensure binding and OpenAPI docs.

Without @RequestParam, Spring may not bind it and OpenAPI may not render it as query param.

Apply:

     @Parameter(description = "Branch name to return translations from")
-    branch: String? = null,
+    @RequestParam(value = "branch", required = false)
+    branch: String? = null,
🤖 Prompt for AI Agents
In
backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/translation/TranslationsController.kt
around lines 163-165, the branch parameter isn't annotated so Spring won't bind
it as a query parameter and OpenAPI won't display it; annotate the parameter
with @RequestParam(required = false) (and optionally add @Parameter(description
= "Branch name to return translations from")) so it is treated as an optional
query parameter and appears in the generated OpenAPI docs.

): ResponseEntity<Map<String, Any>>? {
return projectLastModifiedManager.onlyWhenProjectDataChanged(request) {
Expand All @@ -168,6 +170,7 @@ When null, resulting file will be a flat key-value object.
translationService.getTranslations(
languageTags = permittedTags,
namespace = ns,
branch = branch,
projectId = projectHolder.project.id,
structureDelimiter = request.getStructureDelimiter(),
filterTag = filterTag,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ class KeyComplexEditHelper(
}

if (isKeyNameModified || isNamespaceChanged) {
edited = keyService.edit(key, dto.name, dto.namespace)
edited = keyService.edit(key, dto.name, dto.namespace, dto.branch)
}

return keyWithDataModelAssembler.toModel(edited)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,12 +69,13 @@ class ExportController(
translationService.getTranslations(
allLanguages.map { it.tag }.toSet(),
null,
projectHolder.project.id,
'.',
)
for ((key, value) in translations) {
zipOutputStream.putNextEntry(ZipEntry(String.format("%s.json", key)))
val data = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsBytes(value)
null,
projectHolder.project.id,
'.',
)
for ((key, value) in translations) {
zipOutputStream.putNextEntry(ZipEntry(String.format("%s.json", key)))
val data = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsBytes(value)
val byteArrayInputStream = ByteArrayInputStream(data)
IOUtils.copy(byteArrayInputStream, zipOutputStream)
byteArrayInputStream.close()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,5 @@ class ContentDeliveryConfigModel(
override var messageFormat: ExportMessageFormat? = null
override var supportArrays: Boolean = false
override var fileStructureTemplate: String? = null
override var filterBranch: String? = null
}
2 changes: 2 additions & 0 deletions backend/api/src/main/kotlin/io/tolgee/hateoas/key/KeyModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,7 @@ open class KeyModel(
val description: String?,
@Schema(description = "Custom values of the key")
val custom: Map<String, Any?>?,
@Schema(description = "Branch of key", example = "dev")
val branch: String?,
) : RepresentationModel<KeyModel>(),
Serializable
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,6 @@ class KeyModelAssembler :
namespace = view.namespace,
description = view.description,
custom = view.custom as? Map<String, Any?>?,
branch = view.branch,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,5 +37,7 @@ open class KeyWithDataModel(
val pluralArgName: String?,
@Schema(description = "Custom values of the key")
val custom: Map<String, Any?>,
@Schema(description = "Branch of the key")
val branch: String?,
) : RepresentationModel<KeyWithDataModel>(),
Serializable
Original file line number Diff line number Diff line change
Expand Up @@ -37,5 +37,6 @@ class KeyWithDataModelAssembler(
isPlural = entity.isPlural,
pluralArgName = entity.pluralArgName,
custom = entity.keyMeta?.custom ?: mapOf(),
branch = entity.branch?.name,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ class KeyWithScreenshotsModelAssembler(
isPlural = entity.isPlural,
pluralArgName = entity.pluralArgName,
custom = entity.keyMeta?.custom ?: mapOf(),
branch = entity.branch?.name,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,18 @@ class AllKeysControllerTest : ProjectAuthControllerTest("/v2/projects/") {
fun `returns all keys sorted`() {
performProjectAuthGet("all-keys").andPrettyPrint.andIsOk.andAssertThatJson {
node("_embedded.keys") {
isArray.hasSize(3)
isArray.hasSize(4)
node("[0]") {
node("id").isValidId
node("namespace").isNull()
node("name").isEqualTo("first_key")
}
node("[3]") {
node("id").isValidId
node("namespace").isNull()
node("name").isEqualTo("first_key")
node("branch").isEqualTo("dev")
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import io.tolgee.dtos.request.translation.SetTranslationsWithKeyDto
import io.tolgee.fixtures.andAssertThatJson
import io.tolgee.fixtures.andIsBadRequest
import io.tolgee.fixtures.andIsForbidden
import io.tolgee.fixtures.andIsNotFound
import io.tolgee.fixtures.andIsOk
import io.tolgee.fixtures.andPrettyPrint
import io.tolgee.fixtures.isValidId
Expand Down Expand Up @@ -398,6 +399,56 @@ class TranslationsControllerModificationTest : ProjectAuthControllerTest("/v2/pr
testOutdated(translation, true)
}

@ProjectJWTAuthTestMethod
@Test
fun `sets translations for existing key in branch`() {
saveTestData()
performProjectAuthPut(
"/translations",
SetTranslationsWithKeyDto(
"branch key",
null,
mutableMapOf("en" to "English branch key"),
branch = "test-branch",
),
).andIsOk
.andAssertThatJson {
node("translations.en.text").isEqualTo("English branch key")
node("translations.en.id").isValidId
node("keyId").isValidId
node("keyName").isEqualTo("branch key")
}
}

@ProjectJWTAuthTestMethod
@Test
fun `cannot set translations for key in branch without branch provided`() {
saveTestData()
performProjectAuthPut(
"/translations",
SetTranslationsWithKeyDto(
"branch key",
null,
mutableMapOf("en" to "Cannot do that"),
),
).andIsNotFound
}

@ProjectJWTAuthTestMethod
@Test
fun `cannot set translations for key in default branch with different branch provided`() {
saveTestData()
performProjectAuthPut(
"/translations",
SetTranslationsWithKeyDto(
"A key",
null,
mutableMapOf("en" to "Cannot do that"),
branch = "test-branch",
),
).andIsNotFound
}

private fun testOutdated(
translation: Translation,
state: Boolean,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,11 @@ class TranslationsControllerViewTest : ProjectAuthControllerTest("/v2/projects/"
@Test
fun `returns correct data`() {
testData.generateLotOfData()
testData.addDeletedBranch()
testDataService.saveTestData(testData.root)
userAccount = testData.user
performProjectAuthGet("/translations?sort=id").andPrettyPrint.andIsOk.andAssertThatJson {
node("page.totalElements").isNumber.isGreaterThan(BigDecimal(100))
node("page.totalElements").isNumber.isEqualTo(BigDecimal(101))
node("page.size").isEqualTo(20)
node("selectedLanguages") {
isArray.hasSize(2)
Expand Down Expand Up @@ -105,6 +106,67 @@ class TranslationsControllerViewTest : ProjectAuthControllerTest("/v2/projects/"
}
}

@Test
@ProjectJWTAuthTestMethod
fun `return translations from non-branched keys`() {
testData.generateBranchedData(10)
testDataService.saveTestData(testData.root)
userAccount = testData.user
performProjectAuthGet("/translations?sort=id").andPrettyPrint.andIsOk.andAssertThatJson {
// 2 keys from the default branch, 10 keys from the feature branch should be filtered out
node("_embedded.keys").isArray.hasSize(2)
}
}

@Test
@ProjectJWTAuthTestMethod
fun `return translations from default branch only`() {
testData.generateBranchedData(5, "main", true)
testData.generateBranchedData(10)
testDataService.saveTestData(testData.root)
userAccount = testData.user
performProjectAuthGet("/translations?sort=id").andPrettyPrint.andIsOk.andAssertThatJson {
// 2 non-branched keys + 5 keys from the default branch, 10 keys from the feature branch should be filtered out
node("_embedded.keys").isArray.hasSize(7)
}
}

@Test
@ProjectJWTAuthTestMethod
fun `return translations from featured branch only`() {
testData.generateBranchedData(10)
testDataService.saveTestData(testData.root)
userAccount = testData.user
performProjectAuthGet("/translations?sort=id&branch=feature-branch").andPrettyPrint.andIsOk.andAssertThatJson {
// 10 keys from the feature branch should be returned
node("_embedded.keys").isArray.hasSize(10)
node("_embedded.keys[0].keyName").isEqualTo("key from branch feature-branch 1")
node("_embedded.keys[0].translations.en") {
node("text").isEqualTo("I am key 1's english translation from branch feature-branch.")
}
node("_embedded.keys[1].translations.de") {
node("text").isEqualTo("I am key 2's german translation from branch feature-branch.")
}
}
}

/**
* Edge-case testing returning correct translations if there is soft-deleted branch with same name as active branch
* (deleted is in the process of hard-deleting)
*/
@Test
@ProjectJWTAuthTestMethod
fun `return translations from active branch only`() {
testData.generateBranchedData(10)
testData.addDeletedBranch()
testDataService.saveTestData(testData.root)
userAccount = testData.user
performProjectAuthGet("/translations?sort=id&branch=feature-branch").andPrettyPrint.andIsOk.andAssertThatJson {
// 10 keys from feature-branch (translation from soft-deleted feature-branch is ignored)
node("_embedded.keys").isArray.hasSize(10)
}
}

@Test
@ProjectJWTAuthTestMethod
fun `returns correct comment counts`() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ class SingleStepImportControllerTest : ProjectAuthControllerTest("/v2/projects/"
listOf(Pair(jsonFileName, simpleJson)),
params = mapOf("createNewKeys" to false),
)

executeInNewTransaction {
keyService.find(testData.project.id, "test", null).assert.isNull()
}
Expand Down
Loading
Loading