Skip to content

Commit 01ea18f

Browse files
authored
Merge pull request #275 from contentstack/claude/feat/DX-31564-java-branch-create-environments
feat(branch): environment list on branch create (DX-31564)
2 parents 8d00706 + 75dd982 commit 01ea18f

7 files changed

Lines changed: 347 additions & 3 deletions

File tree

‎changelog.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Changelog
22

3+
## v1.16.0
4+
5+
### Oct 28, 2026
6+
7+
- Feat: Branch create accepts an optional `branch.publish_config.environments` list of environment names, so a branch can clone only the environments it needs. `[]` clones none; omitting `publish_config` clones all environments, as before. The list is sent unchanged, the SDK adds no length limit, and an unknown name (or a mix) returns the API's 422 error body unchanged with no branch created.
8+
39
## v1.15.0
410

511
### Oct 21, 2026

‎pom.xml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
<artifactId>cms</artifactId>
88
<packaging>jar</packaging>
99
<name>contentstack-management-java</name>
10-
<version>1.15.0</version>
10+
<version>1.16.0</version>
1111
<description>Contentstack Java Management SDK for Content Management API, Contentstack is a headless CMS with an
1212
API-first approach
1313
</description>

‎src/main/java/com/contentstack/cms/stack/Branch.java‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,32 @@ public Call<ResponseBody> fetch() {
159159
/**
160160
* The Create a branch request creates a new branch in a particular stack of
161161
* your organization.
162+
* <p>
163+
* Optionally set {@code branch.publish_config.environments} to a list of
164+
* environment <b>names</b> (not UIDs) to choose which environments the branch
165+
* clones:
166+
* <ul>
167+
* <li>a list of names clones only those environments;</li>
168+
* <li>an empty list {@code []} clones none;</li>
169+
* <li>omitting {@code publish_config} clones all environments, as before.</li>
170+
* </ul>
171+
* The list is sent unchanged. The SDK does not validate the names or limit the
172+
* list length. An unknown name, or a mix of known and unknown names, is
173+
* rejected by the API with a 422; the API's error body is returned unchanged
174+
* and no branch is created.
175+
* <p>
176+
* Example body:
177+
* <pre>{@code
178+
* JSONObject publishConfig = new JSONObject();
179+
* publishConfig.put("environments", Arrays.asList("development", "staging"));
180+
* JSONObject branch = new JSONObject();
181+
* branch.put("uid", "branch_uid");
182+
* branch.put("source", "main");
183+
* branch.put("publish_config", publishConfig);
184+
* JSONObject body = new JSONObject();
185+
* body.put("branch", branch);
186+
* stack.branch().create(body).execute();
187+
* }</pre>
162188
*
163189
* @param body the request body
164190
* @return Call

‎src/test/java/com/contentstack/cms/UnitTestSuite.java‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import com.contentstack.cms.core.AuthInterceptorTest;
44
import com.contentstack.cms.core.EndpointTest;
55
import com.contentstack.cms.stack.AssetUnitTest;
6+
import com.contentstack.cms.stack.BranchUnitTest;
67
import com.contentstack.cms.stack.EntryVariantUnitTest;
78
import com.contentstack.cms.stack.EnvironmentUnitTest;
89
import com.contentstack.cms.stack.GlobalFieldUnitTests;
@@ -33,6 +34,7 @@
3334
AssetUnitTest.class,
3435
EntryVariantUnitTest.class,
3536
EnvironmentUnitTest.class,
37+
BranchUnitTest.class,
3638
GlobalFieldUnitTests.class,
3739
LocaleUnitTest.class,
3840
ReleaseUnitTest.class,

‎src/test/java/com/contentstack/cms/stack/APISanityTestSuite.java‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
EndpointAPITest.class,
1212
TaxonomyAPITest.class,
1313
AssetAPITest.class,
14+
BranchAPITest.class,
1415
ContentTypeAPITest.class,
1516
EntryFieldsAPITest.class,
1617
EnvironmentAPITest.class,
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
package com.contentstack.cms.stack;
2+
3+
import com.contentstack.cms.TestClient;
4+
5+
import okhttp3.ResponseBody;
6+
import org.json.simple.JSONObject;
7+
import org.json.simple.parser.JSONParser;
8+
import org.junit.jupiter.api.*;
9+
import retrofit2.Response;
10+
11+
import java.io.IOException;
12+
import java.util.ArrayList;
13+
import java.util.Arrays;
14+
import java.util.Collections;
15+
import java.util.HashSet;
16+
import java.util.List;
17+
18+
import static org.junit.jupiter.api.Assertions.*;
19+
20+
/**
21+
* Live checks for {@code publish_config.environments} on branch create (DX-31564).
22+
* <p>
23+
* Needs a real stack in {@code .env}; without credentials the whole class is skipped.
24+
* The environment names {@value #ENV_DEVELOPMENT} and {@value #ENV_PRODUCTION} must exist
25+
* on the test stack. The source branch {@value #SOURCE} must exist too.
26+
*/
27+
@Tag("api")
28+
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
29+
public class BranchAPITest {
30+
31+
static final String ENV_DEVELOPMENT = "development";
32+
static final String ENV_PRODUCTION = "production";
33+
static final String UNKNOWN_ENV = "no_such_env_dx31564";
34+
static final String SOURCE = "main";
35+
36+
/**
37+
* Unique per run, so reruns on the same stack do not collide. Branch UIDs allow at most
38+
* 15 characters, lowercase letters and digits only: "b" plus the last 5 base-36 digits of
39+
* the current time, then one letter per UID below (7 characters in total).
40+
*/
41+
private static final String RUN = runId();
42+
private static final String UID_TWO = RUN + "a";
43+
private static final String UID_EMPTY = RUN + "b";
44+
private static final String UID_UNKNOWN = RUN + "c";
45+
private static final String UID_MIXED = RUN + "d";
46+
47+
private static String runId() {
48+
String time = Long.toString(System.currentTimeMillis(), 36);
49+
return "b" + time.substring(time.length() - 5);
50+
}
51+
52+
/** Branches this class created, deleted in {@link #cleanUp()}. */
53+
private static final List<String> CREATED = new ArrayList<>();
54+
55+
private static Stack stack;
56+
57+
@BeforeAll
58+
static void setup() {
59+
try {
60+
stack = TestClient.getStack();
61+
} catch (ExceptionInInitializerError | NoClassDefFoundError e) {
62+
// TestClient loads .env in its static initializer; without it the class cannot load.
63+
Assumptions.assumeTrue(false, "Skipping: TestClient could not load .env");
64+
}
65+
Assumptions.assumeFalse(TestClient.isUsingDefaultStackCredentials(),
66+
"Skipping: stack credentials not set in .env");
67+
}
68+
69+
@AfterAll
70+
static void cleanUp() {
71+
for (String uid : CREATED) {
72+
try {
73+
stack.branch(uid).delete().execute();
74+
} catch (IOException e) {
75+
System.out.println("Cleanup failed for branch " + uid + ": " + e.getLocalizedMessage());
76+
}
77+
}
78+
}
79+
80+
@Test
81+
@Order(1)
82+
void createWithTwoEnvironmentNames() throws IOException {
83+
Response<ResponseBody> response = stack.branch()
84+
.create(body(UID_TWO, Arrays.asList(ENV_DEVELOPMENT, ENV_PRODUCTION))).execute();
85+
CREATED.add(UID_TWO);
86+
assertTrue(response.isSuccessful(), "create with two environment names should succeed");
87+
}
88+
89+
@Test
90+
@Order(2)
91+
void fetchBranchCarriesTwoEnvironmentNames() throws Exception {
92+
Response<ResponseBody> response = stack.branch(UID_TWO).fetch().execute();
93+
assertTrue(response.isSuccessful(), "fetch of the created branch should succeed");
94+
assertEquals(new HashSet<>(Arrays.asList(ENV_DEVELOPMENT, ENV_PRODUCTION)),
95+
new HashSet<>(environmentsOf(response)));
96+
}
97+
98+
@Test
99+
@Order(3)
100+
void createWithEmptyListClonesNoEnvironments() throws Exception {
101+
Response<ResponseBody> response = stack.branch()
102+
.create(body(UID_EMPTY, Collections.<String>emptyList())).execute();
103+
CREATED.add(UID_EMPTY);
104+
assertTrue(response.isSuccessful(), "create with an empty list should succeed");
105+
106+
Response<ResponseBody> fetched = stack.branch(UID_EMPTY).fetch().execute();
107+
assertTrue(fetched.isSuccessful(), "fetch of the created branch should succeed");
108+
assertTrue(environmentsOf(fetched).isEmpty(), "an empty list must come back empty");
109+
}
110+
111+
@Test
112+
@Order(4)
113+
void createWithUnknownNameIsRejectedAndCreatesNoBranch() throws Exception {
114+
Response<ResponseBody> response = stack.branch()
115+
.create(body(UID_UNKNOWN, Collections.singletonList(UNKNOWN_ENV))).execute();
116+
CREATED.add(UID_UNKNOWN);
117+
assertEquals(422, response.code(), "an unknown environment name should give the API's 422");
118+
assertNotNull(response.errorBody(), "the API's error body should be present");
119+
assertTrue(response.errorBody().string().contains("publish_config.environments"),
120+
"the 422 body should name publish_config.environments");
121+
122+
Response<ResponseBody> fetched = stack.branch(UID_UNKNOWN).fetch().execute();
123+
assertFalse(fetched.isSuccessful(), "no branch should exist after a rejected create");
124+
}
125+
126+
@Test
127+
@Order(5)
128+
void createWithMixedNamesIsRejectedAndCreatesNoBranch() throws Exception {
129+
Response<ResponseBody> response = stack.branch()
130+
.create(body(UID_MIXED, Arrays.asList(ENV_DEVELOPMENT, UNKNOWN_ENV))).execute();
131+
CREATED.add(UID_MIXED);
132+
assertEquals(422, response.code(), "a mix with an unknown name should give the API's 422");
133+
assertNotNull(response.errorBody(), "the API's error body should be present");
134+
assertTrue(response.errorBody().string().contains("publish_config.environments"),
135+
"the 422 body should name publish_config.environments");
136+
137+
Response<ResponseBody> fetched = stack.branch(UID_MIXED).fetch().execute();
138+
assertFalse(fetched.isSuccessful(), "no branch should exist after a rejected create");
139+
}
140+
141+
/** Builds {@code {"branch": {uid, source, publish_config: {environments}}}}. */
142+
private static JSONObject body(String uid, List<String> environments) {
143+
JSONObject publishConfig = new JSONObject();
144+
publishConfig.put("environments", environments);
145+
JSONObject branch = new JSONObject();
146+
branch.put("uid", uid);
147+
branch.put("source", SOURCE);
148+
branch.put("publish_config", publishConfig);
149+
JSONObject root = new JSONObject();
150+
root.put("branch", branch);
151+
return root;
152+
}
153+
154+
/** Reads {@code branch.publish_config.environments} from a fetch response. */
155+
private static List<String> environmentsOf(Response<ResponseBody> response) throws Exception {
156+
JSONObject json = (JSONObject) new JSONParser().parse(response.body().string());
157+
JSONObject branch = (JSONObject) json.get("branch");
158+
assertNotNull(branch, "the fetch response should carry a 'branch' object");
159+
JSONObject publishConfig = (JSONObject) branch.get("publish_config");
160+
assertNotNull(publishConfig, "the branch should carry a 'publish_config' object");
161+
Object environments = publishConfig.get("environments");
162+
assertNotNull(environments, "publish_config should carry an 'environments' list");
163+
List<String> names = new ArrayList<>();
164+
for (Object name : (List<?>) environments) {
165+
names.add((String) name);
166+
}
167+
return names;
168+
}
169+
}

0 commit comments

Comments
 (0)