Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
c765db7
feat: Branch entity and controller
dkrizan Sep 17, 2025
b0a68a4
feat: handle branch in key/translation operations
dkrizan Sep 17, 2025
06a546a
feat: copy branch data service
dkrizan Sep 19, 2025
17dc6c3
feat: add branch filter to controllers related to keys/translations &…
dkrizan Sep 22, 2025
781946c
feat: KeyController branching modifications
dkrizan Sep 23, 2025
4736353
feat: create the default branch on Project creation
dkrizan Sep 23, 2025
3e39270
chore: fix KeysDistanceUnitTestData.kt
dkrizan Sep 23, 2025
d5cf14e
chore: Branch copy test improved - checks data consistency
dkrizan Sep 23, 2025
554d886
feat: Branch copy test improved - copies all key/translation-related …
dkrizan Sep 24, 2025
3408997
feat: create a key with either default branch (if it exists) or provi…
dkrizan Oct 1, 2025
912d60b
feat: basic branch selector
dkrizan Oct 2, 2025
aac588e
fix: create a default branch when creating a project in service
dkrizan Oct 2, 2025
6bcf925
feat: add branch parameters to Key create form
dkrizan Oct 3, 2025
cfddcdc
fix: KeysTestData.kt - add default main branch
dkrizan Oct 3, 2025
ae6cd4e
feat: Branches page - list and create a new branch form
dkrizan Oct 3, 2025
a3e33c6
fix: it shows the correct branch in branch selector on the first app …
dkrizan Oct 6, 2025
fb16af8
fix: do not return archived branches in branches endpoint
dkrizan Oct 6, 2025
2ed2c66
feat: branch deletion (ux + async clean up service)
dkrizan Oct 7, 2025
6a39a5b
fix: branch unique conditional index on project_id and name only on n…
dkrizan Oct 7, 2025
21169fc
fix: ignore a deleted branch in queries + delete branch entity after …
dkrizan Oct 7, 2025
64e2986
chore: branch data copy service execution improvements
dkrizan Oct 7, 2025
28f666d
feat: create a default branch on the first load of branches
dkrizan Oct 7, 2025
7019c2f
fix: tests
dkrizan Oct 8, 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 @@ -129,8 +129,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 @@ -150,7 +152,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 @@ -160,6 +160,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 +163 to 165
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>>? {
val lastModified: Long = projectTranslationLastModifiedManager.getLastModified(projectHolder.project.id)
Expand All @@ -176,6 +178,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 @@ -148,7 +148,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 @@ -64,6 +64,7 @@ class ExportController(
translationService.getTranslations(
allLanguages.map { it.tag }.toSet(),
null,
null,
projectHolder.project.id,
'.',
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,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,4 +21,6 @@ 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 @@ -18,5 +18,6 @@ class KeyModelAssembler : RepresentationModelAssemblerSupport<KeyView, KeyModel>
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,4 +37,6 @@ 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 @@ -32,5 +32,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 @@ -34,6 +34,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 @@ -390,6 +391,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 @@ -111,6 +111,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
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ class KeyControllerCreationTest : ProjectAuthControllerTest("/v2/projects/") {
.andIsCreated.andPrettyPrint.andAssertThatJson {
node("id").isValidId
node("name").isEqualTo("super_key")
node("branch").isEqualTo("main")
}
}

Expand All @@ -82,6 +83,17 @@ class KeyControllerCreationTest : ProjectAuthControllerTest("/v2/projects/") {
}
}

@ProjectJWTAuthTestMethod
@Test
fun `creates key with default branch (branch parameter not provided)`() {
performProjectAuthPost("keys", CreateKeyDto(name = "super_key_to_main_branch", branch = "dev"))
.andIsCreated.andAssertThatJson {
node("id").isValidId
node("name").isEqualTo("super_key_to_main_branch")
node("branch").isEqualTo("dev")
}
}
Comment on lines +86 to +95
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

Fix misleading test name.

The test name states "creates key with default branch (branch parameter not provided)" but the test actually provides an explicit branch parameter (branch = "dev"). This is misleading and contradicts the test's actual behavior.

Apply this diff to correct the test name:

-  fun `creates key with default branch (branch parameter not provided)`() {
-    performProjectAuthPost("keys", CreateKeyDto(name = "super_key_to_main_branch", branch = "dev"))
+  fun `creates key with explicit branch`() {
+    performProjectAuthPost("keys", CreateKeyDto(name = "super_key_to_dev_branch", branch = "dev"))
       .andIsCreated.andAssertThatJson {
         node("id").isValidId
-        node("name").isEqualTo("super_key_to_main_branch")
+        node("name").isEqualTo("super_key_to_dev_branch")
         node("branch").isEqualTo("dev")
       }
   }
🤖 Prompt for AI Agents
In
backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2KeyController/KeyControllerCreationTest.kt
around lines 86 to 95, the test name claims it verifies creation with the
default branch when branch is not provided, but the test call supplies branch =
"dev"; update the test name to reflect that an explicit branch is provided
(e.g., rename the function to indicate "creates key with provided branch (branch
parameter provided)") so the name matches the test behavior.


@ProjectJWTAuthTestMethod
@Test
fun `validates description`() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,4 +144,13 @@ class KeyControllerKeySearchTest : ProjectAuthControllerTest("/v2/projects/"), L
time.assert.isLessThan(4000)
}
}

@Test
@ProjectJWTAuthTestMethod
fun `it search in default branch only`() {
saveAndPrepare()
performProjectAuthGet("keys/search?search=this-is-branched-key&languageTag=de").andAssertThatJson {
node("_embedded").isAbsent()
}
}
Comment on lines +148 to +155
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

🧩 Analysis chain

Fix test name consistency and verify test data setup.

  1. Test name inconsistency: The test is named "it search in default branch only" but should be "it searches in default branch only" to match the naming pattern of other tests in this file (e.g., "it searches", "it searches for prefix").

  2. Verify test data: Ensure that KeySearchTestData creates a key named "this-is-branched-key" in a non-default branch. Without seeing the test data setup, it's unclear if this branched key actually exists, which could lead to a false positive test.

Apply this diff to fix the test name:

-  fun `it search in default branch only`() {
+  fun `it searches in default branch only`() {

Run the following script to verify the test data includes a branched key:


🏁 Script executed:

#!/bin/bash
# Verify that KeySearchTestData creates a branched key named "this-is-branched-key"
ast-grep --pattern $'class KeySearchTestData {
  $$$
}'
rg -A 20 "class KeySearchTestData" --type=kt

Length of output: 26


#!/bin/bash
# Locate KeySearchTestData definition
find . -type f -name "*KeySearchTestData*.kt"

# Inspect the class declaration for test data setup
rg -n "class KeySearchTestData" -C5 .

# Search for the branched key name within the repository
rg -n "this-is-branched-key" -C5 .

🏁 Script executed:

#!/bin/bash
# Locate the KeySearchTestData Kotlin file
find . -type f -name "*KeySearchTestData*.kt"

# Search for the branched key literal across all Kotlin files
rg -n "this-is-branched-key" -g '*.kt'

Length of output: 490


🏁 Script executed:

#!/bin/bash
# Show class header to see project and branch setup
sed -n '1,50p' backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/KeySearchTestData.kt

# Inspect addKeyWithTranslations implementation
rg -n "fun addKeyWithTranslations" -C10 backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/KeySearchTestData.kt

# Search for branch creation within the file
rg -n "branch" -C5 backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/KeySearchTestData.kt

Length of output: 2500


Rename test function for naming consistency
Apply the diff:

-  fun `it search in default branch only`() {
+  fun `it searches in default branch only`() {
🤖 Prompt for AI Agents
In
backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2KeyController/KeyControllerKeySearchTest.kt
around lines 148 to 155, rename the test function from `it search in default
branch only` to `it searches in default branch only` to match project naming
conventions; update the function declaration accordingly (keep annotations and
body unchanged) and ensure any references or usages are adjusted to the new
name.

}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import java.math.BigDecimal

@SpringBootTest
@AutoConfigureMockMvc
Expand All @@ -43,6 +44,7 @@ class KeyControllerTest : ProjectAuthControllerTest("/v2/projects/") {
@Test
fun `returns all keys`() {
testData.addNKeys(120)
testData.addNBranchedKeys(10)
saveTestDataAndPrepare()
performProjectAuthGet("keys")
.andIsOk.andAssertThatJson {
Expand All @@ -53,6 +55,7 @@ class KeyControllerTest : ProjectAuthControllerTest("/v2/projects/") {
node("[2].namespace").isEqualTo("null")
node("[1].description").isEqualTo("description")
}
node("page.totalElements").isNumber.isEqualTo(BigDecimal(123))
}
performProjectAuthGet("keys?page=1")
.andIsOk.andAssertThatJson {
Expand All @@ -65,6 +68,34 @@ class KeyControllerTest : ProjectAuthControllerTest("/v2/projects/") {
}
}

@ProjectJWTAuthTestMethod
@Test
fun `returns all keys from branch`() {
testData.addNKeys(5)
testData.addNBranchedKeys(110)
saveTestDataAndPrepare()
performProjectAuthGet("keys?branch=feature")
.andIsOk.andAssertThatJson {
node("_embedded.keys") {
isArray.hasSize(20)
node("[0].id").isValidId
node("[1].name").isEqualTo("branch_key_2")
node("[1].description").isEqualTo("description of branched key")
node("[2].namespace").isEqualTo("null")
}
node("page.totalElements").isNumber.isEqualTo(BigDecimal(110))
}
performProjectAuthGet("keys?page=1&branch=feature")
.andIsOk.andAssertThatJson {
node("_embedded.keys") {
isArray.hasSize(20)
node("[0].id").isValidId
node("[1].name").isEqualTo("branch_key_22")
node("[2].namespace").isEqualTo("null")
}
}
}

@ProjectJWTAuthTestMethod
@Test
fun `returns single key`() {
Expand Down
Loading
Loading