Skip to content

Commit 9a86efd

Browse files
Merge pull request #23 from shakuzen/aozora
Create Bluesky Post
2 parents 35e9a5e + e038dc0 commit 9a86efd

File tree

2 files changed

+118
-21
lines changed

2 files changed

+118
-21
lines changed

src/main/java/io/micrometer/release/single/NotificationSender.java

Lines changed: 85 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
*/
1616
package io.micrometer.release.single;
1717

18+
import com.fasterxml.jackson.databind.JsonNode;
19+
import com.fasterxml.jackson.databind.ObjectMapper;
1820
import io.micrometer.release.common.Input;
1921
import org.slf4j.Logger;
2022
import org.slf4j.LoggerFactory;
@@ -25,6 +27,7 @@
2527
import java.net.http.HttpRequest;
2628
import java.net.http.HttpResponse;
2729
import java.net.http.HttpResponse.BodyHandlers;
30+
import java.time.ZonedDateTime;
2831
import java.time.format.DateTimeFormatter;
2932
import java.util.List;
3033

@@ -44,7 +47,7 @@ void sendNotifications(String repoName, String refName, MilestoneWithDeadline ne
4447

4548
// for tests
4649
BlueSkyNotifier blueSky() {
47-
return new BlueSkyNotifier();
50+
return new BlueSkyNotifier(new ObjectMapper());
4851
}
4952

5053
// for tests
@@ -106,8 +109,8 @@ private void notifyGoogleChat(String payload) {
106109
.header("Content-Type", "application/json")
107110
.POST(HttpRequest.BodyPublishers.ofString(payload))
108111
.build();
109-
try {
110-
HttpResponse<String> send = HttpClient.newHttpClient().send(chatRequest, BodyHandlers.ofString());
112+
try (HttpClient httpClient = HttpClient.newHttpClient()) {
113+
HttpResponse<String> send = httpClient.send(chatRequest, BodyHandlers.ofString());
111114
if (send.statusCode() >= 400) {
112115
throw new IllegalStateException("Unexpected response code: " + send.statusCode());
113116
}
@@ -121,19 +124,25 @@ private void notifyGoogleChat(String payload) {
121124

122125
static class BlueSkyNotifier implements Notifier {
123126

127+
private static final Logger log = LoggerFactory.getLogger(BlueSkyNotifier.class);
128+
129+
private final ObjectMapper objectMapper;
130+
124131
private final String uriRoot;
125132

126133
private final String identifier;
127134

128135
private final String password;
129136

130-
BlueSkyNotifier(String uriRoot, String identifier, String password) {
137+
BlueSkyNotifier(ObjectMapper objectMapper, String uriRoot, String identifier, String password) {
138+
this.objectMapper = objectMapper;
131139
this.uriRoot = uriRoot;
132140
this.identifier = identifier;
133141
this.password = password;
134142
}
135143

136-
BlueSkyNotifier() {
144+
BlueSkyNotifier(ObjectMapper objectMapper) {
145+
this.objectMapper = objectMapper;
137146
this.uriRoot = "https://bsky.social";
138147
this.identifier = Input.getBlueSkyHandle();
139148
this.password = Input.getBlueSkyPassword();
@@ -146,26 +155,90 @@ public void sendNotification(String repoName, String refName, MilestoneWithDeadl
146155
return;
147156
}
148157

149-
HttpRequest blueskyRequest = HttpRequest.newBuilder()
158+
String token = getToken();
159+
160+
createPost(token, createPostJson(repoName, refName));
161+
}
162+
163+
private String getToken() {
164+
HttpRequest createSessionRequest = HttpRequest.newBuilder()
150165
.uri(URI.create(uriRoot + "/xrpc/com.atproto.server.createSession"))
151166
.header("Content-Type", "application/json")
152167
.POST(HttpRequest.BodyPublishers
153168
.ofString("{\"identifier\":\"" + identifier + "\",\"password\":\"" + password + "\"}"))
154169
.build();
155170

156-
try {
157-
HttpResponse<String> blueskyResponse = HttpClient.newHttpClient()
158-
.send(blueskyRequest, HttpResponse.BodyHandlers.ofString());
159-
log.info("Bluesky response: " + blueskyResponse.body());
160-
if (blueskyResponse.statusCode() >= 400) {
161-
throw new IllegalStateException("Unexpected response code: " + blueskyResponse.statusCode());
171+
try (HttpClient httpClient = HttpClient.newHttpClient()) {
172+
HttpResponse<String> createSessionResponse = httpClient.send(createSessionRequest,
173+
HttpResponse.BodyHandlers.ofString());
174+
if (createSessionResponse.statusCode() >= 400) {
175+
throw new IllegalStateException("Unexpected response code: " + createSessionResponse.statusCode());
176+
}
177+
JsonNode jsonNode = objectMapper.readTree(createSessionResponse.body());
178+
if (!jsonNode.has("accessJwt")) {
179+
throw new IllegalStateException("Missing JWT in response");
162180
}
181+
return jsonNode.get("accessJwt").asText();
163182
}
164183
catch (IOException | InterruptedException e) {
165184
throw new RuntimeException(e);
166185
}
167186
}
168187

188+
private void createPost(String token, String postJson) {
189+
String requestBody = """
190+
{
191+
"repo":"%s",
192+
"collection":"app.bsky.feed.post",
193+
"record":%s
194+
}""".formatted(identifier, postJson);
195+
HttpRequest createRecordRequest = HttpRequest.newBuilder()
196+
.uri(URI.create(uriRoot + "/xrpc/com.atproto.repo.createRecord"))
197+
.header("Content-Type", "application/json")
198+
.header("Authorization", "Bearer " + token)
199+
.POST(HttpRequest.BodyPublishers.ofString(requestBody))
200+
.build();
201+
202+
try (HttpClient httpClient = HttpClient.newHttpClient()) {
203+
HttpResponse<String> createRecordResponse = httpClient.send(createRecordRequest,
204+
BodyHandlers.ofString());
205+
if (createRecordResponse.statusCode() >= 400) {
206+
log.error("Unexpected response code: {}\nResponse: {}\nRequest: {}",
207+
createRecordResponse.statusCode(), createRecordResponse.body(), requestBody);
208+
throw new IllegalStateException("Unexpected response code: " + createRecordResponse.statusCode());
209+
}
210+
else {
211+
log.debug("Created record: Request: {}, Response: {}", requestBody, createRecordResponse.body());
212+
}
213+
String postRevision = objectMapper.readTree(createRecordResponse.body()).at("/commit/rev").asText();
214+
log.info("Bluesky post created: https://bsky.app/profile/{}/post/{}", identifier, postRevision);
215+
}
216+
catch (IOException | InterruptedException e) {
217+
throw new RuntimeException(e);
218+
}
219+
}
220+
221+
private String createPostJson(String projectName, String versionRef) {
222+
String version = versionRef.startsWith("v") ? versionRef.substring(1) : versionRef;
223+
String postText = "%s %s has been released!\\n\\nCheck out the changelog at https://github.com/%s/releases/tag/%s"
224+
.formatted(projectName, version, projectName, versionRef);
225+
String facetsJson = createFacetsJson(postText);
226+
return """
227+
{
228+
"$type": "app.bsky.feed.post",
229+
"text": "%s",
230+
"createdAt": "%s",
231+
"facets": [
232+
%s
233+
]
234+
}""".formatted(postText, ZonedDateTime.now().format(DateTimeFormatter.ISO_INSTANT), facetsJson);
235+
}
236+
237+
private String createFacetsJson(String postText) {
238+
// TODO this is needed for the URL in the post to be a hyperlink
239+
return "";
240+
}
241+
169242
}
170243

171244
}

src/test/java/io/micrometer/release/single/NotificationSenderTests.java

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,10 @@
1515
*/
1616
package io.micrometer.release.single;
1717

18+
import static com.github.tomakehurst.wiremock.client.WireMock.*;
1819
import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig;
1920

20-
import com.github.tomakehurst.wiremock.client.WireMock;
21+
import com.fasterxml.jackson.databind.ObjectMapper;
2122
import com.github.tomakehurst.wiremock.junit5.WireMockExtension;
2223
import org.junit.jupiter.api.Test;
2324
import org.junit.jupiter.api.extension.RegisterExtension;
@@ -46,22 +47,45 @@ void should_not_send_messages_when_notifiers_not_set_properly() {
4647
}
4748

4849
static void assertThatNotificationGotSent(WireMockExtension wireMockExtension) {
49-
wireMockExtension
50-
.verify(WireMock.postRequestedFor(WireMock.urlEqualTo("/xrpc/com.atproto.server.createSession")));
51-
wireMockExtension.verify(WireMock.postRequestedFor(WireMock.urlEqualTo("/")));
50+
wireMockExtension.verify(postRequestedFor(urlEqualTo("/xrpc/com.atproto.server.createSession")));
51+
wireMockExtension.verify(postRequestedFor(urlEqualTo("/xrpc/com.atproto.repo.createRecord")));
52+
wireMockExtension.verify(postRequestedFor(urlEqualTo("/")));
5253
}
5354

5455
static void assertThatNoNotificationGotSent(WireMockExtension wireMockExtension) {
55-
wireMockExtension.verify(0,
56-
WireMock.postRequestedFor(WireMock.urlEqualTo("/xrpc/com.atproto.server.createSession")));
57-
wireMockExtension.verify(0, WireMock.postRequestedFor(WireMock.urlEqualTo("/")));
56+
wireMockExtension.verify(0, postRequestedFor(urlEqualTo("/xrpc/com.atproto.server.createSession")));
57+
wireMockExtension.verify(0, postRequestedFor(urlEqualTo("/xrpc/com.atproto.repo.createRecord")));
58+
wireMockExtension.verify(0, postRequestedFor(urlEqualTo("/")));
5859
}
5960

6061
static NotificationSender testNotificationSender(WireMockExtension extension) {
62+
extension.stubFor(post("/xrpc/com.atproto.server.createSession").willReturn(okJson("""
63+
{
64+
"accessJwt": "string",
65+
"refreshJwt": "string",
66+
"handle": "string",
67+
"did": "string",
68+
"didDoc": {},
69+
"email": "string",
70+
"emailConfirmed": true,
71+
"emailAuthFactor": true,
72+
"active": true,
73+
"status": "takendown"
74+
}""")));
75+
extension.stubFor(post("/xrpc/com.atproto.repo.createRecord").willReturn(okJson("""
76+
{
77+
"uri": "string",
78+
"cid": "string",
79+
"commit": {
80+
"cid": "string",
81+
"rev": "string"
82+
},
83+
"validationStatus": "valid"
84+
}""")));
6185
return new NotificationSender() {
6286
@Override
6387
BlueSkyNotifier blueSky() {
64-
return new BlueSkyNotifier(extension.baseUrl(), "identifier", "password");
88+
return new BlueSkyNotifier(new ObjectMapper(), extension.baseUrl(), "identifier", "password");
6589
}
6690

6791
@Override
@@ -76,7 +100,7 @@ static NotificationSender emptyNotifications(WireMockExtension extension) {
76100
@Override
77101
BlueSkyNotifier blueSky() {
78102
super.blueSky(); // to ensure no exception is thrown
79-
return new BlueSkyNotifier(extension.baseUrl(), "identifier", "");
103+
return new BlueSkyNotifier(new ObjectMapper(), extension.baseUrl(), "identifier", "");
80104
}
81105

82106
@Override

0 commit comments

Comments
 (0)