Skip to content

Commit 2bc6752

Browse files
dkrizanclaude
andcommitted
feat: add branching support to resolvable import endpoints
Add branch parameter to both import-resolvable and single-step-import-resolvable endpoints, enabling Figma plugin and other API consumers to import keys to specific branches. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 50ca4d4 commit 2bc6752

8 files changed

Lines changed: 287 additions & 3 deletions

File tree

backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/dataImport/SingleStepImportController.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ class SingleStepImportController(
105105
fun singleStepResolvableImport(
106106
@RequestBody @Valid params: SingleStepImportResolvableRequest,
107107
): ImportResult {
108+
projectFeatureGuard.checkIfUsed(Feature.BRANCHING, params.branch)
108109
return singleStepImportService.singleStepImportResolvable(
109110
project = projectHolder.projectEntity,
110111
userAccount = authenticationFacade.authenticatedUserEntity,

backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/keys/KeyController.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -270,9 +270,11 @@ class KeyController(
270270
fun importKeys(
271271
@RequestBody @Valid
272272
dto: ImportKeysResolvableDto,
273+
@RequestParam branch: String? = null,
273274
): KeyImportResolvableResultModel {
275+
projectFeatureGuard.checkIfUsed(Feature.BRANCHING, branch)
274276
val uploadedImageToScreenshotMap =
275-
keyService.importKeysResolvable(dto.keys, projectHolder.projectEntity)
277+
keyService.importKeysResolvable(dto.keys, projectHolder.projectEntity, branch)
276278
val screenshots =
277279
uploadedImageToScreenshotMap.screenshots
278280
.map { (uploadedImageId, screenshot) ->

backend/data/src/main/kotlin/io/tolgee/dtos/request/importKeysResolvable/SingleStepImportResolvableRequest.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ data class SingleStepImportResolvableRequest(
2020
"Unresolved conflicts are reported in the `params` of the error response",
2121
)
2222
val errorOnUnresolvedConflict: Boolean? = null,
23+
@field:Schema(
24+
description = "Branch to import keys into. If not specified, default branch is used.",
25+
)
26+
val branch: String? = null,
2327
@get:Schema(
2428
description = "List of keys to import",
2529
)

backend/data/src/main/kotlin/io/tolgee/service/dataImport/SingleStepImportService.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ class SingleStepImportService(
113113
keysToFilesManager.processKeys(params.keys)
114114

115115
val request = SingleStepImportRequest()
116+
request.branch = params.branch
116117
request.overrideMode = params.overrideMode ?: OverrideMode.RECOMMENDED
117118
request.errorOnUnresolvedConflict = params.errorOnUnresolvedConflict
118119

backend/data/src/main/kotlin/io/tolgee/service/key/KeyService.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -539,12 +539,14 @@ class KeyService(
539539
fun importKeysResolvable(
540540
keys: List<ImportKeysResolvableItemDto>,
541541
projectEntity: Project,
542+
branch: String? = null,
542543
): KeyImportResolvableResult {
543544
val importer =
544545
ResolvingKeyImporter(
545546
applicationContext = applicationContext,
546547
keysToImport = keys,
547548
projectEntity = projectEntity,
549+
branch = branch,
548550
)
549551
return importer()
550552
}

backend/data/src/main/kotlin/io/tolgee/service/key/ResolvingKeyImporter.kt

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import io.tolgee.model.Language
1515
import io.tolgee.model.Project
1616
import io.tolgee.model.Project_
1717
import io.tolgee.model.Screenshot
18+
import io.tolgee.model.branching.Branch
19+
import io.tolgee.model.branching.Branch_
1820
import io.tolgee.model.enums.Scope
1921
import io.tolgee.model.key.Key
2022
import io.tolgee.model.key.Key_
@@ -37,6 +39,7 @@ class ResolvingKeyImporter(
3739
val applicationContext: ApplicationContext,
3840
val keysToImport: List<ImportKeysResolvableItemDto>,
3941
val projectEntity: Project,
42+
val branch: String? = null,
4043
) {
4144
private val entityManager = applicationContext.getBean(EntityManager::class.java)
4245
private val keyService = applicationContext.getBean(KeyService::class.java)
@@ -317,7 +320,7 @@ class ResolvingKeyImporter(
317320
project = projectEntity,
318321
name = keyToImport.name,
319322
namespace = keyToImport.namespace,
320-
branch = keyToImport.branch,
323+
branch = branch ?: keyToImport.branch,
321324
isPlural = false,
322325
)
323326
}
@@ -335,6 +338,22 @@ class ResolvingKeyImporter(
335338
@Suppress("UNCHECKED_CAST")
336339
val namespaceJoin: Join<Key, Namespace> = root.fetch(Key_.namespace, JoinType.LEFT) as Join<Key, Namespace>
337340

341+
@Suppress("UNCHECKED_CAST")
342+
val branchJoin: Join<Key, Branch> = root.fetch(Key_.branch, JoinType.LEFT) as Join<Key, Branch>
343+
344+
val branchPredicate =
345+
if (branch.isNullOrEmpty()) {
346+
cb.or(
347+
branchJoin.get(Branch_.id).isNull,
348+
cb.isTrue(branchJoin.get(Branch_.isDefault)),
349+
)
350+
} else {
351+
cb.and(
352+
cb.equal(branchJoin.get(Branch_.name), cb.literal(branch)),
353+
cb.isNull(branchJoin.get(Branch_.deletedAt)),
354+
)
355+
}
356+
338357
val predicates =
339358
keys
340359
.map { (namespace, name) ->
@@ -347,7 +366,12 @@ class ResolvingKeyImporter(
347366
val projectIdPath = root.get(Key_.project).get(Project_.id)
348367

349368
query.where(
350-
cb.and(cb.equal(projectIdPath, projectId), cb.isNull(root.get(Key_.deletedAt)), cb.or(*predicates)),
369+
cb.and(
370+
cb.equal(projectIdPath, projectId),
371+
cb.isNull(root.get(Key_.deletedAt)),
372+
branchPredicate,
373+
cb.or(*predicates),
374+
),
351375
)
352376

353377
return this.entityManager.createQuery(query).resultList

ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/branching/KeyControllerBranchingTest.kt

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,4 +250,68 @@ class KeyControllerBranchingTest : ProjectAuthControllerTest("/v2/projects/") {
250250
),
251251
).andPrettyPrint.andIsBadRequest.andHasErrorMessage(Message.FEATURE_NOT_ENABLED)
252252
}
253+
254+
@ProjectJWTAuthTestMethod
255+
@Test
256+
fun `import-resolvable imports keys to branch`() {
257+
enabledFeaturesProvider.forceEnabled = setOf(Feature.BRANCHING)
258+
performProjectAuthPost(
259+
"keys/import-resolvable?branch=dev",
260+
mapOf(
261+
"keys" to
262+
listOf(
263+
mapOf(
264+
"name" to "new_resolvable_key",
265+
"translations" to
266+
mapOf(
267+
"en" to
268+
mapOf(
269+
"text" to "hello resolvable",
270+
"resolution" to "NEW",
271+
),
272+
),
273+
),
274+
),
275+
),
276+
).andIsOk
277+
278+
executeInNewTransaction {
279+
val project = projectService.get(testData.project.id)
280+
val key =
281+
project.keys.find {
282+
it.name == "new_resolvable_key" && it.branch?.name == "dev"
283+
}
284+
key.assert.isNotNull
285+
key!!
286+
.translations
287+
.find { it.language.tag == "en" }!!
288+
.text.assert
289+
.isEqualTo("hello resolvable")
290+
}
291+
}
292+
293+
@ProjectJWTAuthTestMethod
294+
@Test
295+
fun `import-resolvable fails when branch specified but feature not enabled`() {
296+
enabledFeaturesProvider.forceEnabled = emptySet()
297+
performProjectAuthPost(
298+
"keys/import-resolvable?branch=feature",
299+
mapOf(
300+
"keys" to
301+
listOf(
302+
mapOf(
303+
"name" to "some_key",
304+
"translations" to
305+
mapOf(
306+
"en" to
307+
mapOf(
308+
"text" to "hello",
309+
"resolution" to "NEW",
310+
),
311+
),
312+
),
313+
),
314+
),
315+
).andIsBadRequest.andHasErrorMessage(Message.FEATURE_NOT_ENABLED)
316+
}
253317
}
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
package io.tolgee.ee.api.v2.controllers.branching
2+
3+
import io.tolgee.ProjectAuthControllerTest
4+
import io.tolgee.constants.Feature
5+
import io.tolgee.constants.Message
6+
import io.tolgee.development.testDataBuilder.data.dataImport.SingleStepImportBranchTestData
7+
import io.tolgee.ee.component.PublicEnabledFeaturesProvider
8+
import io.tolgee.fixtures.andHasErrorMessage
9+
import io.tolgee.fixtures.andIsBadRequest
10+
import io.tolgee.fixtures.andIsOk
11+
import io.tolgee.testing.annotations.ProjectJWTAuthTestMethod
12+
import io.tolgee.testing.assert
13+
import org.junit.jupiter.api.BeforeEach
14+
import org.junit.jupiter.api.Test
15+
import org.springframework.beans.factory.annotation.Autowired
16+
17+
@Suppress("SpringJavaInjectionPointsAutowiringInspection")
18+
class SingleStepImportResolvableBranchingTest : ProjectAuthControllerTest("/v2/projects/") {
19+
lateinit var testData: SingleStepImportBranchTestData
20+
21+
@Autowired
22+
lateinit var enabledFeaturesProvider: PublicEnabledFeaturesProvider
23+
24+
@BeforeEach
25+
fun setup() {
26+
testData = SingleStepImportBranchTestData()
27+
testDataService.saveTestData(testData.root)
28+
userAccount = testData.user
29+
projectSupplier = { testData.project }
30+
}
31+
32+
@Test
33+
@ProjectJWTAuthTestMethod
34+
fun `imports new keys to specified branch`() {
35+
enabledFeaturesProvider.forceEnabled = setOf(Feature.BRANCHING)
36+
performProjectAuthPost(
37+
"single-step-import-resolvable",
38+
mapOf(
39+
"branch" to testData.featureBranch.name,
40+
"keys" to
41+
listOf(
42+
mapOf(
43+
"name" to "new_key",
44+
"translations" to
45+
mapOf(
46+
"en" to
47+
mapOf(
48+
"text" to "hello",
49+
"resolution" to "OVERRIDE",
50+
),
51+
),
52+
),
53+
),
54+
),
55+
).andIsOk
56+
57+
executeInNewTransaction {
58+
val key = keyService.getAllByBranch(testData.project.id, "feature").find { it.name == "new_key" }
59+
key.assert.isNotNull
60+
key!!
61+
.translations
62+
.find { it.language.tag == "en" }!!
63+
.text.assert
64+
.isEqualTo("hello")
65+
}
66+
}
67+
68+
@Test
69+
@ProjectJWTAuthTestMethod
70+
fun `imports to default branch when no branch specified`() {
71+
performProjectAuthPost(
72+
"single-step-import-resolvable",
73+
mapOf(
74+
"keys" to
75+
listOf(
76+
mapOf(
77+
"name" to "new_key",
78+
"translations" to
79+
mapOf(
80+
"en" to
81+
mapOf(
82+
"text" to "hello",
83+
"resolution" to "OVERRIDE",
84+
),
85+
),
86+
),
87+
),
88+
),
89+
).andIsOk
90+
91+
executeInNewTransaction {
92+
val key = keyService.getAllByBranch(testData.project.id, "main").find { it.name == "new_key" }
93+
key.assert.isNotNull
94+
key!!
95+
.translations
96+
.find { it.language.tag == "en" }!!
97+
.text.assert
98+
.isEqualTo("hello")
99+
}
100+
}
101+
102+
@Test
103+
@ProjectJWTAuthTestMethod
104+
fun `keys imported to branch are not visible on default branch`() {
105+
enabledFeaturesProvider.forceEnabled = setOf(Feature.BRANCHING)
106+
performProjectAuthPost(
107+
"single-step-import-resolvable",
108+
mapOf(
109+
"branch" to testData.featureBranch.name,
110+
"keys" to
111+
listOf(
112+
mapOf(
113+
"name" to "branch_only_key",
114+
"translations" to
115+
mapOf(
116+
"en" to
117+
mapOf(
118+
"text" to "on branch",
119+
"resolution" to "OVERRIDE",
120+
),
121+
),
122+
),
123+
),
124+
),
125+
).andIsOk
126+
127+
executeInNewTransaction {
128+
val keysOnDefault = keyService.getAllByBranch(testData.project.id, "main")
129+
keysOnDefault.find { it.name == "branch_only_key" }.assert.isNull()
130+
131+
val keysOnFeature = keyService.getAllByBranch(testData.project.id, "feature")
132+
keysOnFeature.find { it.name == "branch_only_key" }.assert.isNotNull
133+
}
134+
}
135+
136+
@Test
137+
@ProjectJWTAuthTestMethod
138+
fun `fails when branch specified but feature not enabled`() {
139+
enabledFeaturesProvider.forceEnabled = emptySet()
140+
performProjectAuthPost(
141+
"single-step-import-resolvable",
142+
mapOf(
143+
"branch" to testData.featureBranch.name,
144+
"keys" to
145+
listOf(
146+
mapOf(
147+
"name" to "some_key",
148+
"translations" to
149+
mapOf(
150+
"en" to
151+
mapOf(
152+
"text" to "hello",
153+
"resolution" to "OVERRIDE",
154+
),
155+
),
156+
),
157+
),
158+
),
159+
).andIsBadRequest.andHasErrorMessage(Message.FEATURE_NOT_ENABLED)
160+
}
161+
162+
@Test
163+
@ProjectJWTAuthTestMethod
164+
fun `succeeds without branch when feature not enabled`() {
165+
enabledFeaturesProvider.forceEnabled = emptySet()
166+
performProjectAuthPost(
167+
"single-step-import-resolvable",
168+
mapOf(
169+
"keys" to
170+
listOf(
171+
mapOf(
172+
"name" to "some_key",
173+
"translations" to
174+
mapOf(
175+
"en" to
176+
mapOf(
177+
"text" to "hello",
178+
"resolution" to "OVERRIDE",
179+
),
180+
),
181+
),
182+
),
183+
),
184+
).andIsOk
185+
}
186+
}

0 commit comments

Comments
 (0)