Skip to content

Commit 54a810d

Browse files
committed
Add a way to "sync" issue body/assignee only, by a query
1 parent 9a310fe commit 54a810d

File tree

6 files changed

+271
-200
lines changed

6 files changed

+271
-200
lines changed

src/main/java/org/hibernate/infra/replicate/jira/service/jira/JiraService.java

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import org.hibernate.infra.replicate.jira.JiraConfig;
1616
import org.hibernate.infra.replicate.jira.service.jira.client.JiraRestClient;
1717
import org.hibernate.infra.replicate.jira.service.jira.client.JiraRestClientBuilder;
18+
import org.hibernate.infra.replicate.jira.service.jira.handler.JiraIssueSimpleUpsertEventHandler;
1819
import org.hibernate.infra.replicate.jira.service.jira.handler.JiraIssueTransitionOnlyEventHandler;
1920
import org.hibernate.infra.replicate.jira.service.jira.model.hook.JiraWebHookEvent;
2021
import org.hibernate.infra.replicate.jira.service.jira.model.hook.JiraWebHookIssue;
@@ -169,9 +170,8 @@ public void registerManagementRoutes(@Observes ManagementInterface mi) {
169170
}
170171

171172
context.submitTask(() -> {
172-
syncByQuery(query, context, jiraIssue -> {
173-
context.submitTask(new JiraIssueTransitionOnlyEventHandler(reportingConfig, context, jiraIssue));
174-
});
173+
syncByQuery(query, context, jiraIssue -> context
174+
.submitTask(new JiraIssueTransitionOnlyEventHandler(reportingConfig, context, jiraIssue)));
175175
});
176176
rc.end();
177177
});
@@ -208,6 +208,22 @@ public void registerManagementRoutes(@Observes ManagementInterface mi) {
208208
context.submitTask(() -> syncByQuery(query, context));
209209
rc.end();
210210
});
211+
mi.router().get("/sync/issues/query/simple/:project").blockingHandler(rc -> {
212+
// syncs only assignee/body, without links comments and transitions
213+
JsonObject request = rc.body().asJsonObject();
214+
String project = request.getString("project");
215+
String query = request.getString("query");
216+
217+
HandlerProjectContext context = contextPerProject.get(project);
218+
219+
if (context == null) {
220+
throw new IllegalArgumentException("Unknown project '%s'".formatted(project));
221+
}
222+
223+
syncByQuery(query, context, jiraIssue -> context
224+
.submitTask(new JiraIssueSimpleUpsertEventHandler(reportingConfig, context, jiraIssue)));
225+
rc.end();
226+
});
211227
mi.router().post("/sync/comments/list").consumes(MediaType.APPLICATION_JSON).blockingHandler(rc -> {
212228
JsonObject request = rc.body().asJsonObject();
213229
String project = request.getString("project");
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
package org.hibernate.infra.replicate.jira.service.jira.handler;
2+
3+
import java.net.URI;
4+
import java.util.ArrayList;
5+
import java.util.List;
6+
import java.util.Objects;
7+
import java.util.Optional;
8+
9+
import org.hibernate.infra.replicate.jira.service.jira.HandlerProjectContext;
10+
import org.hibernate.infra.replicate.jira.service.jira.client.JiraRestException;
11+
import org.hibernate.infra.replicate.jira.service.jira.model.rest.JiraFields;
12+
import org.hibernate.infra.replicate.jira.service.jira.model.rest.JiraIssue;
13+
import org.hibernate.infra.replicate.jira.service.jira.model.rest.JiraIssueLink;
14+
import org.hibernate.infra.replicate.jira.service.jira.model.rest.JiraRemoteLink;
15+
import org.hibernate.infra.replicate.jira.service.jira.model.rest.JiraSimpleObject;
16+
import org.hibernate.infra.replicate.jira.service.jira.model.rest.JiraTextContent;
17+
import org.hibernate.infra.replicate.jira.service.jira.model.rest.JiraTransition;
18+
import org.hibernate.infra.replicate.jira.service.jira.model.rest.JiraUser;
19+
import org.hibernate.infra.replicate.jira.service.reporting.ReportingConfig;
20+
21+
abstract class JiraIssueAbstractEventHandler extends JiraEventHandler {
22+
23+
public JiraIssueAbstractEventHandler(ReportingConfig reportingConfig, HandlerProjectContext context, Long id) {
24+
super(reportingConfig, context, id);
25+
}
26+
27+
protected void applyTransition(JiraIssue sourceIssue, String destinationKey) {
28+
prepareTransition(sourceIssue).ifPresent(
29+
jiraTransition -> context.destinationJiraClient().transition(destinationKey, jiraTransition));
30+
}
31+
32+
protected void updateIssueBody(JiraIssue sourceIssue, String destinationKey) {
33+
JiraIssue issue = issueToCreate(sourceIssue);
34+
35+
updateIssue(destinationKey, issue, sourceIssue, context.notMappedAssignee());
36+
}
37+
38+
protected void updateIssue(String destinationKey, JiraIssue issue, JiraIssue sourceIssue, JiraUser assignee) {
39+
try {
40+
context.destinationJiraClient().update(destinationKey, issue);
41+
} catch (JiraRestException e) {
42+
if (issue.fields.assignee == null) {
43+
// if we failed with no assignee then there's no point in retrying ...
44+
throw e;
45+
}
46+
if (e.getMessage().contains("\"assignee\"")) {
47+
// let's try updating with the assignee passed to the method (it may be the
48+
// "notMappedAssignee")
49+
failureCollector.warning(
50+
"Unable to update issue %s with assignee %s, will try to update one more time with assignee %s."
51+
.formatted(sourceIssue.key, issue.fields.assignee, assignee),
52+
e);
53+
issue.fields.assignee = assignee;
54+
updateIssue(destinationKey, issue, sourceIssue, null);
55+
} else {
56+
throw e;
57+
}
58+
}
59+
}
60+
61+
protected JiraRemoteLink remoteSelfLink(JiraIssue sourceIssue) {
62+
URI jiraLink = createJiraIssueUri(sourceIssue);
63+
64+
JiraRemoteLink link = new JiraRemoteLink();
65+
// >> Setting this field enables the remote issue link details to be updated or
66+
// deleted using remote system
67+
// >> and item details as the record identifier, rather than using the record's
68+
// Jira ID.
69+
//
70+
// Hence, we set this global id as a link to the issue, this way it should be
71+
// unique enough and easy to create:
72+
link.globalId = jiraLink.toString();
73+
link.relationship = "Upstream issue";
74+
link.object.title = sourceIssue.key;
75+
link.object.url = jiraLink;
76+
link.object.summary = "Link to an upstream JIRA issue, from which this one was cloned from.";
77+
78+
return link;
79+
}
80+
81+
protected JiraIssue issueToCreate(JiraIssue sourceIssue) {
82+
JiraIssue destinationIssue = new JiraIssue();
83+
destinationIssue.fields = new JiraFields();
84+
85+
destinationIssue.fields.summary = sourceIssue.fields.summary;
86+
destinationIssue.fields.description = sourceIssue.fields.description;
87+
destinationIssue.fields.description = "%s%s".formatted(prepareDescriptionQuote(sourceIssue),
88+
Objects.toString(sourceIssue.fields.description, ""));
89+
destinationIssue.fields.description = truncateContent(destinationIssue.fields.description);
90+
91+
destinationIssue.fields.labels = sourceIssue.fields.labels;
92+
// let's also add fix versions to the labels
93+
if (sourceIssue.fields.fixVersions != null) {
94+
if (destinationIssue.fields.labels == null) {
95+
destinationIssue.fields.labels = List.of();
96+
}
97+
destinationIssue.fields.labels = new ArrayList<>(destinationIssue.fields.labels);
98+
for (JiraSimpleObject fixVersion : sourceIssue.fields.fixVersions) {
99+
destinationIssue.fields.labels.add("Fix version:%s".formatted(fixVersion.name).replace(' ', '_'));
100+
}
101+
}
102+
103+
// if we can map the priority - great we'll do that, if no: we'll keep it blank
104+
// and let Jira use its default instead:
105+
destinationIssue.fields.priority = sourceIssue.fields.priority != null
106+
? priority(sourceIssue.fields.priority.id).map(JiraSimpleObject::new).orElse(null)
107+
: null;
108+
109+
destinationIssue.fields.project.id = context.project().projectId();
110+
111+
destinationIssue.fields.issuetype = issueType(sourceIssue.fields.issuetype.id).map(JiraSimpleObject::new)
112+
.orElse(null);
113+
114+
// now let's handle the users. we will consider only a mapped subset of users
115+
// and for other's the defaults will be used.
116+
// also the description is going to include a section mentioning who created and
117+
// who the issue is assigned to...
118+
if (context.projectGroup().canSetReporter()) {
119+
destinationIssue.fields.reporter = user(sourceIssue.fields.reporter).map(this::toUser)
120+
.orElseGet(context::notMappedAssignee);
121+
}
122+
123+
destinationIssue.fields.assignee = user(sourceIssue.fields.assignee).map(this::toUser).orElse(null);
124+
125+
return destinationIssue;
126+
}
127+
128+
private JiraUser toUser(String value) {
129+
return new JiraUser(context.projectGroup().users().mappedPropertyName(), value);
130+
}
131+
132+
private Optional<JiraTransition> prepareTransition(JiraIssue sourceIssue) {
133+
return statusToTransition(sourceIssue.fields.status.id).map(
134+
tr -> new JiraTransition(tr, "Upstream issue status updated to: " + sourceIssue.fields.status.name));
135+
}
136+
137+
protected Optional<JiraIssueLink> prepareParentLink(String destinationKey, JiraIssue sourceIssue) {
138+
if (sourceIssue.fields.parent != null) {
139+
String parent = toDestinationKey(sourceIssue.fields.parent.key);
140+
// we don't really need it, but as usual we are making sure that the issue is
141+
// available downstream:
142+
context.createNextPlaceholderBatch(parent);
143+
JiraIssueLink link = new JiraIssueLink();
144+
link.type.id = context.projectGroup().issueLinkTypes().parentLinkType();
145+
// "name": "Depend",
146+
// "inward": "is depended on by",
147+
// "outward": "depends on",
148+
//
149+
// TODO: Jira is sending a relates-to link created hook on its own
150+
// and it has the inward/outward sides opposite to what we do here
151+
// Let's double check what will happen with actual downstream jira
152+
// (there a depends on link should be created along side the one triggered
153+
// automatically by the source JIRA).
154+
link.inwardIssue.key = destinationKey;
155+
link.outwardIssue.key = parent;
156+
return Optional.of(link);
157+
} else {
158+
return Optional.empty();
159+
}
160+
}
161+
162+
private String prepareDescriptionQuote(JiraIssue issue) {
163+
URI issueUri = createJiraIssueUri(issue);
164+
URI reporterUri = createJiraUserUri(issue.self, issue.fields.reporter);
165+
URI assigneeUri = createJiraUserUri(issue.self, issue.fields.assignee);
166+
return """
167+
{quote}This issue is created as a copy of [%s|%s].
168+
169+
Assigned to: %s.
170+
171+
Reported by: %s.{quote}
172+
173+
174+
""".formatted(issue.key, issueUri,
175+
assigneeUri == null
176+
? " Unassigned"
177+
: "[user %s|%s]".formatted(JiraTextContent.userIdPart(issue.fields.assignee), assigneeUri),
178+
reporterUri == null
179+
? " Unknown"
180+
: "[user %s|%s]".formatted(JiraTextContent.userIdPart(issue.fields.reporter), reporterUri));
181+
}
182+
183+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package org.hibernate.infra.replicate.jira.service.jira.handler;
2+
3+
import org.hibernate.infra.replicate.jira.service.jira.HandlerProjectContext;
4+
import org.hibernate.infra.replicate.jira.service.jira.client.JiraRestException;
5+
import org.hibernate.infra.replicate.jira.service.jira.model.rest.JiraIssue;
6+
import org.hibernate.infra.replicate.jira.service.reporting.ReportingConfig;
7+
8+
abstract class JiraIssueInternalAbstractEventHandler extends JiraIssueAbstractEventHandler {
9+
10+
private final JiraIssue sourceIssue;
11+
12+
protected JiraIssueInternalAbstractEventHandler(ReportingConfig reportingConfig, HandlerProjectContext context,
13+
JiraIssue issue) {
14+
super(reportingConfig, context, issue.id);
15+
this.sourceIssue = issue;
16+
}
17+
18+
@Override
19+
protected final void doRun() {
20+
// NOTE: we do not look up the source issue as we've already queried for it
21+
// before creating this handler:
22+
String destinationKey = toDestinationKey(sourceIssue.key);
23+
// We don't really need one, but doing so means that we will create the
24+
// placeholder for it if the issue wasn't already present in the destination
25+
// Jira instance
26+
context.createNextPlaceholderBatch(destinationKey);
27+
28+
try {
29+
updateAction(destinationKey, sourceIssue);
30+
} catch (JiraRestException e) {
31+
failureCollector
32+
.critical("Unable to update destination issue %s: %s".formatted(destinationKey, e.getMessage()), e);
33+
}
34+
}
35+
36+
protected abstract void updateAction(String destinationKey, JiraIssue sourceIssue);
37+
38+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package org.hibernate.infra.replicate.jira.service.jira.handler;
2+
3+
import org.hibernate.infra.replicate.jira.service.jira.HandlerProjectContext;
4+
import org.hibernate.infra.replicate.jira.service.jira.model.rest.JiraIssue;
5+
import org.hibernate.infra.replicate.jira.service.reporting.ReportingConfig;
6+
7+
public class JiraIssueSimpleUpsertEventHandler extends JiraIssueInternalAbstractEventHandler {
8+
9+
public JiraIssueSimpleUpsertEventHandler(ReportingConfig reportingConfig, HandlerProjectContext context,
10+
JiraIssue issue) {
11+
super(reportingConfig, context, issue);
12+
}
13+
14+
@Override
15+
protected void updateAction(String destinationKey, JiraIssue sourceIssue) {
16+
updateIssueBody(sourceIssue, destinationKey);
17+
}
18+
19+
@Override
20+
public String toString() {
21+
return "JiraIssueSimpleUpsertEventHandler[" + "objectId=" + objectId + ", project=" + context.projectName()
22+
+ ']';
23+
}
24+
}
Lines changed: 4 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,24 @@
11
package org.hibernate.infra.replicate.jira.service.jira.handler;
22

3-
import java.util.Optional;
4-
53
import org.hibernate.infra.replicate.jira.service.jira.HandlerProjectContext;
6-
import org.hibernate.infra.replicate.jira.service.jira.client.JiraRestException;
74
import org.hibernate.infra.replicate.jira.service.jira.model.rest.JiraIssue;
8-
import org.hibernate.infra.replicate.jira.service.jira.model.rest.JiraTransition;
95
import org.hibernate.infra.replicate.jira.service.reporting.ReportingConfig;
106

11-
public class JiraIssueTransitionOnlyEventHandler extends JiraEventHandler {
12-
13-
private final JiraIssue sourceIssue;
7+
public class JiraIssueTransitionOnlyEventHandler extends JiraIssueInternalAbstractEventHandler {
148

159
public JiraIssueTransitionOnlyEventHandler(ReportingConfig reportingConfig, HandlerProjectContext context,
1610
JiraIssue issue) {
17-
super(reportingConfig, context, issue.id);
18-
this.sourceIssue = issue;
11+
super(reportingConfig, context, issue);
1912
}
2013

2114
@Override
22-
protected void doRun() {
23-
// NOTE: we do not look up the source issue as we've already queried for it
24-
// before creating this handler:
25-
String destinationKey = toDestinationKey(sourceIssue.key);
26-
// We don't really need one, but doing so means that we will create the
27-
// placeholder for it if the issue wasn't already present in the destination
28-
// Jira instance
29-
context.createNextPlaceholderBatch(destinationKey);
30-
31-
try {
32-
// issue status can be updated only through transition:
33-
prepareTransition(sourceIssue).ifPresent(
34-
jiraTransition -> context.destinationJiraClient().transition(destinationKey, jiraTransition));
35-
} catch (JiraRestException e) {
36-
failureCollector
37-
.critical("Unable to update destination issue %s: %s".formatted(destinationKey, e.getMessage()), e);
38-
}
15+
protected void updateAction(String destinationKey, JiraIssue sourceIssue) {
16+
applyTransition(sourceIssue, destinationKey);
3917
}
4018

4119
@Override
4220
public String toString() {
4321
return "JiraIssueTransitionOnlyEventHandler[" + "objectId=" + objectId + ", project=" + context.projectName()
4422
+ ']';
4523
}
46-
47-
private Optional<JiraTransition> prepareTransition(JiraIssue sourceIssue) {
48-
return statusToTransition(sourceIssue.fields.status.id).map(
49-
tr -> new JiraTransition(tr, "Upstream issue status updated to: " + sourceIssue.fields.status.name));
50-
}
51-
5224
}

0 commit comments

Comments
 (0)