9 | * Provides a means of interfacing to the Chat Server that is going to be
10 | * the outbound channel we use to communicate with the team.
11 | *
12 | *
13 | * Mapping of messages to channels is done internally. The post function assumes that we
14 | * know where we want the message to go inside the various channels/rooms, and thus, who
15 | * will see it.
16 | *
17 | *
18 | * Created by Rob on 8/23/14.
19 | * Copyright (c) ontometrics, 2014 All Rights Reserved
20 | */
21 | public interface ChatServer {
22 |
23 | /**
24 | * Put a message about creation of the issue
25 | *
26 | * @param issue created issue
27 | */
28 | void postIssueCreation(Issue issue);
29 |
30 | /**
31 | * Put a message about a change out to the chat server.
32 | *
33 | * @param issueEditSession information about the things changed in a single edit session
34 | */
35 | void post(IssueEditSession issueEditSession);
36 |
37 | /**
38 | * Provides a list of the Users that are members of our chat server team.
39 | *
40 | * @return the usernames that are known members right now
41 | */
42 | // List getUsers();
43 |
44 | /**
45 | *
46 | * @return channel mapper
47 | */
48 | ChannelMapper getChannelMapper();
49 | }
50 |
--------------------------------------------------------------------------------
/src/test/resources/feeds/issue-feed-comment-attachment-only.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | YouTrack
4 | http://ontometrics.com:8085/_rss/issues
5 |
6 |
7 | AIA-38: Support for Attachments
8 | http://ontometrics.com:8085/issue/AIA-38
9 |
10 |
Reporter
Rob Williams (rob)
Created
Sep 4, 2014 9:03:42 PM
Updated
Sep 5, 2014 12:44:52 PM
Resolved
Sep 4, 2014 9:05:08 PM
Priority
Normal
Type
Task
State
Fixed
Assignee
Rob Williams (rob)
Subsystem
No subsystem
Fix versions
0.0.3
Affected versions
Unknown
Fixed in build
Next Build
Currently, attachments do not appear in changes. So we have to have some logic that just looks for attachments that were added in the time window of interest and simply add them as an edit.
The search bar feature won't bring up results unless the term is spelled correctly. Perhaps a potential solution would be to add in functionality similar to what we have when we create a new lead (see screenshot)
Perhaps an auto-suggest function (see screen shot) similar to the functionality of when you are creating a lead would make sense here?
I think it would be great as well that if we spelled a word incorrectly the system will either auto correct and say like "did you mean...." or bring up results from a partial word we bring up. Just some thoughts.
11 | * Represents the record found in the feed that indicates that an
12 | * Issue was touched.
13 | *
14 | *
15 | * Instances of this class are created when the feed is parsed.
16 | *
17 | *
18 | * Because we have to gather more information about the changes that
19 | * were made, this class is not used to communicate the substance of
20 | * what occurred through the chat server.
21 | *
29 | */
30 | @Ignore
31 | public class EditSessionsExtractorIntegrationTest {
32 | private static final Logger log = getLogger(EditSessionsExtractorIntegrationTest.class);
33 |
34 |
35 | private YouTrackInstance youTrackInstance;
36 | private StreamProvider streamProvider;
37 |
38 | @Before
39 | public void setup(){
40 | Configuration configuration = ConfigurationFactory.get();
41 |
42 | streamProvider = AuthenticatedHttpStreamProvider.basicAuthenticatedHttpStreamProvider(
43 | configuration.getString("PROP.YOUTRACK_USERNAME"),
44 | configuration.getString("PROP.YOUTRACK_PASSWORD")
45 | );
46 |
47 | youTrackInstance = new YouTrackInstance.Builder().baseUrl(
48 | configuration.getString("PROP.YOUTRACK_URL")).build();
49 | }
50 |
51 | /*
52 | @Test
53 | public void testThatWeCanGetEventsFromRealFeed() throws Exception {
54 | EditSessionsExtractor sourceEventMapper = new EditSessionsExtractor(youTrackInstance, streamProvider);
55 | List changes = sourceEventMapper.getLatestEvents();
56 | assertThat(changes, not(empty()));
57 | }
58 |
59 | @Test
60 | public void testThatWeCanGetEditSessionsFromRealFeed() throws Exception {
61 | EditSessionsExtractor sourceEventMapper = new EditSessionsExtractor(youTrackInstance, streamProvider);
62 |
63 | //NOTE: min date has been set to "1 day before" to minimize the number of requests
64 | //adjust it, if yours server has no data within that period
65 | Date minDate = new Date(System.currentTimeMillis() - 24*3600*1000);
66 |
67 | List edits = sourceEventMapper.getLatestEdits(minDate);
68 | assertThat(edits, not(empty()));
69 | log.info("found {} edits: {}", edits.size(), edits);
70 | }
71 |
72 | */
73 | }
--------------------------------------------------------------------------------
/src/main/java/com/ontometrics/integrations/events/ProcessEventChange.java:
--------------------------------------------------------------------------------
1 | package com.ontometrics.integrations.events;
2 |
3 | import java.util.Date;
4 |
5 | /**
6 | * Created by rob on 7/23/14.
7 | * Copyright (c) ontometrics, 2014 All Rights Reserved
8 | */
9 | public class ProcessEventChange {
10 |
11 | private Issue issue;
12 | private final String field;
13 | private final String priorValue;
14 | private final String currentValue;
15 | private final String updater;
16 | private final Date updated;
17 |
18 | public ProcessEventChange(Builder builder) {
19 | issue = builder.issue;
20 | updater = builder.updater;
21 | updated = builder.updated;
22 | field = builder.field;
23 | priorValue = builder.priorValue;
24 | currentValue = builder.currentValue;
25 | }
26 |
27 | public static class Builder {
28 |
29 | private String field;
30 | private String priorValue;
31 | private String currentValue;
32 | private String updater;
33 | private Date updated;
34 | private Issue issue;
35 |
36 | public Builder issue(Issue issue){
37 | this.issue = issue;
38 | return this;
39 | }
40 |
41 | public Builder updater(String updater){
42 | this.updater = updater;
43 | return this;
44 | }
45 |
46 | public Builder updated(Date updated){
47 | this.updated = updated;
48 | return this;
49 | }
50 |
51 | public Builder field(String field){
52 | this.field = field;
53 | return this;
54 | }
55 |
56 | public Builder priorValue(String priorValue){
57 | this.priorValue = priorValue;
58 | return this;
59 | }
60 |
61 | public Builder currentValue(String currentVale){
62 | this.currentValue = currentVale;
63 | return this;
64 | }
65 |
66 | public ProcessEventChange build(){
67 | return new ProcessEventChange(this);
68 | }
69 | }
70 |
71 | public Issue getIssue() {
72 | return issue;
73 | }
74 |
75 | public String getField() {
76 | return field;
77 | }
78 |
79 | public String getPriorValue() {
80 | return priorValue;
81 | }
82 |
83 | public String getCurrentValue() {
84 | return currentValue;
85 | }
86 |
87 | public Date getUpdated() {
88 | return updated;
89 | }
90 |
91 | public String getUpdater() {
92 | return updater;
93 | }
94 |
95 | @Override
96 | public String toString() {
97 | StringBuilder stringBuilder = new StringBuilder(String.format("%s changed %s ", updater, field));
98 | if (priorValue.length() > 0) {
99 | stringBuilder.append("from ").append(priorValue).append(" ");
100 | }
101 | stringBuilder.append("to ").append(currentValue);
102 | return stringBuilder.toString();
103 | }
104 | }
105 |
--------------------------------------------------------------------------------
/src/test/resources/feeds/empty-issue-changes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | DMAN
6 |
7 |
8 | 584
9 |
10 |
11 | Display major events (Equinox / Solstice)
12 |
13 |
14 | 1404394803932
15 |
16 |
17 | 1409073351938
18 |
19 |
20 | nikolay.chorniy@gmail.com
21 |
22 |
23 | Nikolay Chorniy
24 |
25 |
26 | noura
27 |
28 |
29 | Noura Hassan
30 |
31 |
32 | 0
33 |
34 |
35 | 0
36 |
37 |
38 | DMAN-573
39 |
40 |
41 | Normal
42 |
43 |
44 | Task
45 |
46 |
47 | Reopened
48 |
49 |
50 | No subsystem
51 |
52 |
53 | 1.0.23
54 |
55 | Star
56 |
57 |
--------------------------------------------------------------------------------
/src/test/resources/feeds/issue-changes-no-changes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | HA
4 |
5 |
6 | 689
7 |
8 |
9 | Fix scraping of title in Monster source
10 |
11 |
12 |
13 | Monster source scraper adds company name and address to the job posting title. Avoid it.
14 |
15 |
16 |
17 | 1409264541170
18 |
19 |
20 | 1409264541170
21 |
22 |
23 | nikolay.chorniy@gmail.com
24 |
25 |
26 | Nikolay Chorniy
27 |
28 |
29 | nikolay.chorniy@gmail.com
30 |
31 |
32 | Nikolay Chorniy
33 |
34 |
35 | 0
36 |
37 |
38 | 0
39 |
40 |
41 | HA-663
42 |
43 |
44 | Normal
45 |
46 |
47 | Bug
48 |
49 |
50 | In Progress
51 |
52 |
53 | nikolay.chorniy@gmail.com
54 |
55 |
56 | No subsystem
57 |
58 |
59 | 2.2
60 |
61 |
--------------------------------------------------------------------------------
/src/test/resources/feeds/issue-change-new-item-with-comment-no-changes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | AIA
4 |
5 |
6 | 19
7 |
8 |
9 | Need to make a monitor Agent
10 |
11 |
12 |
13 | So on JobSpider, we are constantly deploying builds only to have the Customer tell us later that no data is coming through. We need to write a simple monitor that counts the # of items found, imported, geocoded, etc., and then posts a message into the channel from time-to-time. This agent should also have the ability to detect idle periods that are too long, so for example post a message if we have not seen any new items in 5 hours.
14 |
15 |
16 |
17 | 1408804483646
18 |
19 |
20 | 1409328580340
21 |
22 |
23 | rob
24 |
25 |
26 | Rob Williams
27 |
28 |
29 | rob
30 |
31 |
32 | Rob Williams
33 |
34 |
35 | 1
36 |
37 |
38 | 0
39 |
40 |
41 | Normal
42 |
43 |
44 | Story
45 |
46 |
47 | Open
48 |
49 |
50 | Services
51 |
52 |
53 | 0.0.2
54 |
55 |
56 |
57 |
58 |
--------------------------------------------------------------------------------
/src/test/resources/feeds/issue-details-with-link.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | HA
4 |
5 |
6 | 740
7 |
8 |
9 |
10 | Need way for Admins to train the System about Posting Classifications
11 |
12 |
13 |
14 |
15 | We are going to start with the system literally just guessing the Classification. Then we will have an interface that shows the items that have been classified but not verified. Users will have to go through and either click Ok, to agree with the classification, or select the correct classification, which is how the System will train and make better predictions. Eventually, we will make it so that Classifications that obtained a score above a certain threshold will go into the feed immediately (automatically). At first, nothing will be above that threshold so admins will in effect have to approve each item for it to appear in the correct feeds.
16 |
17 |
18 |
19 | 1410471757247
20 |
21 |
22 | 1410472462486
23 |
24 |
25 | rob
26 |
27 |
28 | Rob Williams
29 |
30 |
31 | rob
32 |
33 |
34 | Rob Williams
35 |
36 |
37 | 0
38 |
39 |
40 | 0
41 |
42 |
43 | HA-741
44 |
45 |
46 | Normal
47 |
48 |
49 | Story
50 |
51 |
52 | Open
53 |
54 |
55 | User Interface
56 |
57 |
58 | 2.5.0
59 |
60 |
--------------------------------------------------------------------------------
/src/main/java/com/ontometrics/integrations/events/Issue.java:
--------------------------------------------------------------------------------
1 | package com.ontometrics.integrations.events;
2 |
3 | import java.net.URL;
4 | import java.util.Date;
5 |
6 | /**
7 | * Created by Rob on 8/19/14.
8 | * Copyright (c) ontometrics, 2014 All Rights Reserved
9 | */
10 | public class Issue {
11 |
12 | private final int id;
13 | private final String prefix;
14 | private final String title;
15 | private final String description;
16 | private final URL link;
17 | private final String creator;
18 | private final Date created;
19 |
20 | public Issue(Builder builder) {
21 | id = builder.id;
22 | prefix = builder.prefix;
23 | creator = builder.creator;
24 | created = builder.created;
25 | title = builder.title;
26 | description = builder.description;
27 | link = builder.link;
28 | }
29 |
30 | public static class Builder {
31 |
32 | private int id;
33 | private String prefix;
34 | private String title;
35 | private String description;
36 | private URL link;
37 | private String creator;
38 | private Date created;
39 |
40 | public Builder id(int id){
41 | this.id = id;
42 | return this;
43 | }
44 |
45 | public Builder projectPrefix(String prefix){
46 | this.prefix = prefix;
47 | return this;
48 | }
49 |
50 | public Builder creator(String creator){
51 | this.creator = creator;
52 | return this;
53 | }
54 |
55 | public Builder created(Date created){
56 | this.created = created;
57 | return this;
58 | }
59 |
60 | public Builder title(String title){
61 | this.title = title;
62 | return this;
63 | }
64 |
65 | public Builder description(String description){
66 | this.description = description;
67 | return this;
68 | }
69 |
70 | public Builder link(URL link){
71 | this.link = link;
72 | return this;
73 | }
74 |
75 | public Issue build(){
76 | return new Issue(this);
77 | }
78 | }
79 |
80 | public int getId() {
81 | return id;
82 | }
83 |
84 | public String getPrefix() {
85 | return prefix;
86 | }
87 |
88 | public String getCreator() {
89 | return creator;
90 | }
91 |
92 | public Date getCreated() {
93 | return created;
94 | }
95 |
96 | public String getTitle() {
97 | return title;
98 | }
99 |
100 | public String getDescription() {
101 | return description;
102 | }
103 |
104 | public URL getLink() {
105 | return link;
106 | }
107 |
108 | @Override
109 | public boolean equals(Object o) {
110 | if (this == o) return true;
111 | if (o == null || getClass() != o.getClass()) return false;
112 |
113 | Issue issue = (Issue) o;
114 |
115 | return id == issue.id && !(prefix != null ? !prefix.equals(issue.prefix) : issue.prefix != null);
116 |
117 | }
118 |
119 | @Override
120 | public int hashCode() {
121 | int result = id;
122 | result = 31 * result + (prefix != null ? prefix.hashCode() : 0);
123 | return result;
124 | }
125 |
126 | @Override
127 | public String toString() {
128 | return "Issue{" +
129 | "id=" + id +
130 | ", prefix='" + prefix + '\'' +
131 | ", title='" + title + '\'' +
132 | ", description='" + description + '\'' +
133 | ", link=" + link +
134 | ", creator='" + creator + '\'' +
135 | ", created=" + created +
136 | '}';
137 | }
138 | }
139 |
--------------------------------------------------------------------------------
/src/test/resources/feeds/issue-changes-comment-attachment-only.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | AIA
4 |
5 |
6 | 38
7 |
8 |
9 | Support for Attachments
10 |
11 |
12 |
13 | Currently, attachments do not appear in changes. So we have to have some logic that just looks for attachments that were added in the time window of interest and simply add them as an edit.
14 |
15 |
16 |
17 | 1409889822124
18 |
19 |
20 | 1409946292661
21 |
22 |
23 | rob
24 |
25 |
26 | Rob Williams
27 |
28 |
29 | 1409889908795
30 |
31 |
32 | rob
33 |
34 |
35 | Rob Williams
36 |
37 |
38 | 1
39 |
40 |
41 | 0
42 |
43 |
44 | AIA-39
45 |
46 |
47 | Normal
48 |
49 |
50 | Task
51 |
52 |
53 | Fixed
54 |
55 |
56 | rob
57 |
58 |
59 | No subsystem
60 |
61 |
62 | 0.0.3
63 |
64 |
65 | Screen Shot 2014-08-27 at 4.17.53 PM.png
66 |
67 |
68 |
69 |
70 |
--------------------------------------------------------------------------------
/src/main/java/com/ontometrics/util/DateBuilder.java:
--------------------------------------------------------------------------------
1 | package com.ontometrics.util;
2 |
3 | import com.ontometrics.integrations.events.IssueEdit;
4 |
5 | import java.util.Calendar;
6 | import java.util.Date;
7 | import java.util.TimeZone;
8 |
9 | /**
10 | * Provides a fluent builder interface for constructing Dates and DateTimes.
11 | */
12 | public class DateBuilder {
13 |
14 | private Calendar calendar = Calendar.getInstance(TimeZone.getTimeZone("UTC"));
15 |
16 | /**
17 | * Provides means of starting from a given date, then changing some subset
18 | * of the fields
19 | *
20 | * @param date
21 | * the starting point date
22 | * @return this, for chaining
23 | */
24 | public DateBuilder start(Date date) {
25 | calendar.setTime(date);
26 | return this;
27 | }
28 |
29 | /**
30 | * So to build the first of the year, you would set this to 1.
31 | *
32 | * @param dayOfMonth
33 | * a number between 1 and 31 (depending on the calendar month of
34 | * course)
35 | * @return this, for chaining
36 | */
37 | public DateBuilder day(int dayOfMonth) {
38 | calendar.set(Calendar.DAY_OF_MONTH, dayOfMonth);
39 | return this;
40 | }
41 |
42 | /**
43 | * Provide the month of the date you are building, a number between 1 and
44 | * 12.
45 | *
46 | * @param month
47 | * the value desired in the resulting date, e.g. 5 for May
48 | * @return this, for chaining
49 | */
50 | public DateBuilder month(int month) {
51 | calendar.set(Calendar.MONTH, month);
52 | return this;
53 | }
54 |
55 | /**
56 | * Provide the year of the date you are building, e.g. 1980.
57 | *
58 | * @param year
59 | * the value desired in the resulting date
60 | * @return this, for chaining
61 | */
62 | public DateBuilder year(int year) {
63 | calendar.set(Calendar.YEAR, year);
64 | return this;
65 | }
66 |
67 | /**
68 | * Provide the hour, in this case of the day. Note the JDK calls this hour
69 | * of the day.
70 | *
71 | * @param hour
72 | * the value desired in the resulting date, e.g. 14 for 2 pm
73 | * @return this, for chaining
74 | */
75 | public DateBuilder hour(int hour) {
76 | calendar.set(Calendar.HOUR_OF_DAY, hour);
77 | return this;
78 | }
79 |
80 | /**
81 | * Provide the minutes within the hour of the datetime being constructed
82 | *
83 | * @param minutes
84 | * a number between 0 and 59, e.g. if building 12:49, this would
85 | * be 49.
86 | * @return this, for chaining
87 | */
88 | public DateBuilder minutes(int minutes) {
89 | calendar.set(Calendar.MINUTE, minutes);
90 | return this;
91 | }
92 |
93 | /**
94 | * Provide the seconds within the hour of the datetime being constructed
95 | *
96 | * @param seconds
97 | * a number between 0 and 59
98 | * @return this, for chaining
99 | */
100 | public DateBuilder seconds(int seconds) {
101 | calendar.set(Calendar.SECOND, seconds);
102 | return this;
103 | }
104 |
105 | /**
106 | * Adds specified amount of minutes to the built date
107 | * @param minutes minutes
108 | *
109 | * @return this, for chaining
110 | */
111 | public DateBuilder addMinutes(int minutes) {
112 | calendar.add(Calendar.MINUTE, minutes);
113 | return this;
114 | }
115 |
116 | public DateBuilder addDays(int daysToAdd) {
117 | calendar.add(Calendar.DAY_OF_YEAR, daysToAdd);
118 | return this;
119 | }
120 |
121 | /**
122 | * Sets specified timestamp
123 | * @param time timestamp
124 | *
125 | * @return this, for chaining
126 | */
127 | public DateBuilder time(Date time) {
128 | calendar.setTime(time);
129 | return this;
130 | }
131 |
132 | /**
133 | * Provides access to the final product.
134 | *
135 | * @return the constructed date with all the desired values
136 | */
137 | public Date build() {
138 | return calendar.getTime();
139 | }
140 |
141 | }
142 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | slack-youtrack
2 | ==============
3 |
4 | Integration of slack and you track.
5 |
6 | Main functionality here is change management:
7 |
8 | * show ticket changes
9 | * linkify references to issues
10 | * map the changes to the appropriate channel
11 |
12 | Of course, to do this we have an agent being woken up regularly that:
13 |
14 | 1. Request list of updated issues for each project
15 | 1. for each item found there, calls into the REST interface to get changes
16 | 1. formats the change info
17 | 1. posts it to the channel
18 | 1. load list of youtrack projects sometimes
19 | 1. Retrieve AccessToken from HUB (for "hub-oauth2" type of authentication)
20 |
21 | Installation and configuration
22 | ------------
23 | 1. List of required properties
24 | * SLACK_WEBHOOK_PATH - path for Slack webhook excluding first slash. e.g. "services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX" (see https://api.slack.com/incoming-webhooks)
25 | * YOUTRACK_URL - YouTrack server url. e.g. "https://mycompany.com/youtrack". This can be inner URL (for example local IP-based)
26 | * YOUTRACK_EXTERNAL_URL - YouTrack server external URL. It should be accessible for Slack Users, so consider it to be public DNS-based URL. This can be the same as YOUTRACK_URL however.
27 | * YOUTRACK_TO_SLACK_CHANNELS - mappings between YouTrack projects and Slack channels. For example "APL->#apple;SUP->#support" (please note that "#" or "@" should be included in the slack-channel name
28 | * DEFAULT_SLACK_CHANNEL - Default slack channel to post in. e.g. "#general". Projects without mappings will be posted here
29 | * AUTH_TYPE - authentication / authorization method. Available values: {"credentials", "hub-oauth2"}
30 | * APP_EXTERNAL_URL - external url of deployed slack-youtrack web application. This url is used to render attachment images
31 | * EXCLUDED_YOUTRACK_FIELDS - semicolon separated fields, changing of which should not be reported, e.g. "Spent Time, Estimate Time"
32 |
33 | For "credentials" authentication type such properties have to be created
34 |
35 | * YOUTRACK_USERNAME - YouTrack username
36 | * YOUTRACK_PASSWORD - YouTrack password
37 | * SLACK_WEBHOOK_PATH - path for Slack webhook excluding first slash. e.g. "services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX"
38 |
39 | For "hub-oauth2" authentication type (Hub authentication OAuth 2.0) following properties are required.
40 | See https://www.jetbrains.com/hub/help/1.0/OAuth-2.0-Authorization.html
41 |
42 | * HUB_URL - JetBrains Hub url e.g. "https://mycompany.com/hub"
43 | * HUB_OAUTH_RESOURCE_SERVER_SERVICE_ID - Hub OAuth resource server service id
44 | * HUB_OAUTH_CLIENT_SERVICE_ID - Hub OAuth clientServiceId
45 | * HUB_OAUTH_CLIENT_SERVICE_SECRET - Hub OAuth clientServiceSecret DEFAULT_SLACK_CHANNEL - Default slack channel to post in. e.g. "#general"
46 |
47 | Generic attributes
48 | * ISSUE_HISTORY_WINDOW - Time in minutes - how deep should we look for issues in the past. If set to 10, it means that issues and changes that happened not longer than 10 minutes will be posted to chat server
49 | * APP_DATA_DIR - directory where app will store it's data-files (configuration). e.g. "/opt/slack-youtrack"
50 | * SLACKBOT_ICON - URL of icon used for the posts in the YouTrack channel
51 |
52 | 2. Create maven profile with described properties or directly define them like below
53 |
54 | Run "mvn -DYOUTRACK_USERNAME=usr -DYOUTRACK_PASSWORD=pwd -DSLACK_WEBHOOK_PATH=services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX -DAPP_DATA_DIR=/opt/slack-youtrack -DYOUTRACK_URL=http://company.myjetbrains.com/youtrack -DISSUE_HISTORY_WINDOW=10 -DDEFAULT_SLACK_CHANNEL=#general package" to build war file
55 |
56 | 3. Drop war file into servlet container "webapps" directory
57 |
58 | That's it.
59 |
60 | Troubleshooting
61 | ------------
62 |
63 | If you experience any problems, e.g. YouTrack updates are not posted to Slack channel, rebuild the project setting http client log level to DEBUG (so that all requests and responses are logged), redeploy and feel free to file an issue with information from the log. To set the log level to DEBUG, edit [src/main/resources/logback.xml](https://github.com/ontometrics/slack-youtrack/blob/master/src/main/resources/logback.xml) and uncomment lines
64 |
65 | ```xml
66 |
67 |
68 | ```
69 |
--------------------------------------------------------------------------------
/src/main/java/com/ontometrics/integrations/configuration/YouTrackInstance.java:
--------------------------------------------------------------------------------
1 | package com.ontometrics.integrations.configuration;
2 |
3 | import com.ontometrics.integrations.events.Issue;
4 | import org.slf4j.Logger;
5 |
6 | import java.net.MalformedURLException;
7 | import java.net.URL;
8 | import java.util.Date;
9 |
10 | import static org.slf4j.LoggerFactory.getLogger;
11 |
12 | /**
13 | * Created by rob on 8/19/14.
14 | * Copyright (c) ontometrics, 2014 All Rights Reserved
15 | */
16 | public class YouTrackInstance implements IssueTracker {
17 |
18 | private Logger log = getLogger(YouTrackInstance.class);
19 | private final String baseUrl;
20 | private final String externalBaseUrl;
21 | private final String issueBase;
22 |
23 | public YouTrackInstance(Builder builder) {
24 | baseUrl = builder.baseUrl;
25 | externalBaseUrl = builder.externalBaseUrl;
26 | issueBase = getBaseUrl() + "/rest/issue/%s";
27 | }
28 |
29 |
30 | @Override
31 | public URL getIssueUrl(String issueIdentifier) {
32 | URL url;
33 | try {
34 | url = new URL(String.format("%s/issue/%s", getBaseUrl(), issueIdentifier));
35 | } catch (MalformedURLException e) {
36 | throw new RuntimeException(e);
37 | }
38 | return url;
39 | }
40 |
41 | @Override
42 | public URL getExternalIssueUrl(String issueIdentifier) {
43 | URL url;
44 | try {
45 | url = new URL(String.format("%s/issue/%s", getExternalBaseUrl(), issueIdentifier));
46 | } catch (MalformedURLException e) {
47 | throw new RuntimeException(e);
48 | }
49 | return url;
50 | }
51 |
52 | public String getIssueRestUrl(Issue issue) {
53 | return String.format(issueBase, issue.getPrefix() + "-" + issue.getId());
54 | }
55 |
56 | public static class Builder {
57 |
58 | private String baseUrl;
59 | private String externalBaseUrl;
60 |
61 | public Builder baseUrl(String baseUrl) {
62 | this.baseUrl = baseUrl;
63 | return this;
64 | }
65 |
66 | public Builder externalBaseUrl(String externalBaseUrl) {
67 | this.externalBaseUrl = externalBaseUrl;
68 | return this;
69 | }
70 |
71 | public YouTrackInstance build(){
72 | return new YouTrackInstance(this);
73 | }
74 | }
75 |
76 | @Override
77 | public URL getBaseUrl() {
78 | URL url;
79 | try {
80 | url = new URL(baseUrl);
81 | } catch (MalformedURLException e) {
82 | throw new RuntimeException(e);
83 | }
84 | return url;
85 | }
86 |
87 | @Override
88 | public URL getExternalBaseUrl() {
89 | URL url;
90 | try {
91 | url = new URL(externalBaseUrl);
92 | } catch (MalformedURLException e) {
93 | throw new RuntimeException(e);
94 | }
95 | return url;
96 | }
97 |
98 | @Override
99 | public URL getFeedUrl(String project, Date sinceDate) {
100 | URL url;
101 | try {
102 | url = new URL(String.format("%s/rest/issue/byproject/%s?updatedAfter=%s",
103 | getBaseUrl(), project, Long.toString(sinceDate.getTime())));
104 | } catch (MalformedURLException e) {
105 | throw new RuntimeException(e);
106 | }
107 | return url;
108 | }
109 |
110 | @Override
111 | public URL getChangesUrl(Issue issue){
112 | URL url;
113 | try {
114 | url = new URL(String.format("%s/rest/issue/%s/changes", getBaseUrl(), issue.getPrefix() + "-" + issue.getId()));
115 | } catch (MalformedURLException e) {
116 | throw new RuntimeException(e);
117 | }
118 | return url;
119 | }
120 |
121 | @Override
122 | public URL getAttachmentsUrl(Issue issue) {
123 | return buildIssueURL(issue, "%s/attachment");
124 | }
125 |
126 |
127 |
128 | private URL buildIssueURL(Issue issue, String urlTemplate) {
129 | URL url = null;
130 | try {
131 | String base = getIssueRestUrl(issue);
132 | url = new URL(urlTemplate.replace("%s", base));
133 | } catch (MalformedURLException e) {
134 | log.error("Error building issue URL", e);
135 | }
136 | return url;
137 | }
138 |
139 | }
140 |
--------------------------------------------------------------------------------
/src/main/java/com/ontometrics/integrations/jobs/JobStarter.java:
--------------------------------------------------------------------------------
1 | package com.ontometrics.integrations.jobs;
2 |
3 | import com.ontometrics.integrations.configuration.ConfigurationAccessError;
4 | import com.ontometrics.integrations.configuration.ConfigurationFactory;
5 | import com.ontometrics.integrations.configuration.SlackInstance;
6 | import com.ontometrics.integrations.configuration.StreamProviderFactory;
7 | import com.ontometrics.integrations.sources.ChannelMapper;
8 | import com.ontometrics.integrations.sources.ChannelMapperFactory;
9 | import com.ontometrics.integrations.sources.StreamProvider;
10 | import org.apache.commons.configuration.Configuration;
11 | import org.slf4j.Logger;
12 | import org.slf4j.LoggerFactory;
13 |
14 | import java.net.MalformedURLException;
15 | import java.net.URL;
16 | import java.util.concurrent.Executors;
17 | import java.util.concurrent.ScheduledExecutorService;
18 | import java.util.concurrent.ScheduledFuture;
19 | import java.util.concurrent.TimeUnit;
20 |
21 | /**
22 | * Create and schedule timer which will execute list of {@link EventListener}s
23 | * JobStarter.java
24 | */
25 | public class JobStarter {
26 | private static Logger logger = LoggerFactory.getLogger(JobStarter.class);
27 |
28 | //TODO move to configuration params
29 | private static final long EXECUTION_DELAY = 2 * 1000;
30 | private static final long REPEAT_INTERVAL = 90 * 1000;
31 | private ScheduledExecutorService scheduledExecutorService;
32 | private ScheduledFuture scheduledTask;
33 |
34 | public JobStarter() {
35 | initialize();
36 | }
37 |
38 | /**
39 | * Schedules periodic tasks to fetch the events
40 | */
41 | public void scheduleTasks() {
42 | final Configuration configuration = ConfigurationFactory.get();
43 | StreamProvider streamProvider = StreamProviderFactory.createStreamProvider(configuration);
44 |
45 | ChannelMapper channelMapper = ChannelMapperFactory.fromConfiguration(configuration, "youtrack-slack.");
46 |
47 | SlackInstance chatServer = new SlackInstance.Builder().channelMapper(channelMapper)
48 | .icon(resolveSlackBotIcon(configuration)).build();
49 | scheduleTask(new EventListenerImpl(streamProvider, chatServer));
50 | }
51 |
52 | private String resolveSlackBotIcon(Configuration configuration) {
53 | String slackBotIcon = configuration.getString("youtrack-slack.icon");
54 | try {
55 | new URL(slackBotIcon);
56 | } catch (MalformedURLException e) {
57 | slackBotIcon = SlackInstance.DEFAULT_ICON_URL;
58 | }
59 | return slackBotIcon;
60 | }
61 |
62 |
63 | private void initialize() {
64 | }
65 |
66 | /**
67 | * Schedules a periodic task {@link com.ontometrics.integrations.jobs.EventListener#checkForNewEvents()}
68 | * @param eventListener event listener
69 | */
70 | private void scheduleTask(EventListener eventListener) {
71 | logger.info("Scheduling EventListener task");
72 | EventTask eventTask = new EventTask(eventListener);
73 | scheduledExecutorService = Executors.newScheduledThreadPool(1);
74 |
75 | scheduledTask = scheduledExecutorService
76 | .scheduleWithFixedDelay(eventTask, EXECUTION_DELAY, REPEAT_INTERVAL,
77 | TimeUnit.MILLISECONDS);
78 | }
79 |
80 | private static class EventTask implements Runnable {
81 | private EventListener eventListener;
82 |
83 | private EventTask(EventListener eventListener) {
84 | this.eventListener = eventListener;
85 | }
86 |
87 | @Override
88 | public void run() {
89 | logger.info("Event processing started");
90 | try {
91 | this.eventListener.checkForNewEvents();
92 | logger.info("Event processing finished");
93 | } catch (ConfigurationAccessError error) {
94 | //this is critical error
95 | throw error;
96 | } catch (Throwable ex) {
97 | logger.error("Failed to process", ex);
98 | }
99 | }
100 | }
101 |
102 | public void dispose () {
103 | //cancelling all previously launched tasks and timer
104 | if (scheduledTask != null) {
105 | scheduledTask.cancel(false);
106 | }
107 |
108 | if (scheduledExecutorService != null) {
109 | scheduledExecutorService.shutdown();
110 | }
111 | }
112 | }
113 |
--------------------------------------------------------------------------------
/src/main/java/com/ontometrics/integrations/configuration/EventProcessorConfiguration.java:
--------------------------------------------------------------------------------
1 | package com.ontometrics.integrations.configuration;
2 |
3 | import com.ontometrics.util.DateBuilder;
4 | import org.apache.commons.configuration.ConfigurationException;
5 | import org.apache.commons.configuration.PropertiesConfiguration;
6 | import org.slf4j.Logger;
7 | import org.slf4j.LoggerFactory;
8 |
9 | import java.io.File;
10 | import java.util.Date;
11 |
12 | /**
13 | * EventProcessorConfiguration.java
14 | * Organize access (read/write) to properties/state required for processing of input/output streams
15 | *
16 | */
17 | public class EventProcessorConfiguration {
18 | private static final Logger logger = LoggerFactory.getLogger(EventProcessorConfiguration.class);
19 |
20 | private static final EventProcessorConfiguration instance = new EventProcessorConfiguration();
21 |
22 | private static final String LAST_EVENT_DATE = "last.event.date";
23 |
24 | public static final String PROP_ISSUE_HISTORY_WINDOW = "PROP.ISSUE_HISTORY_WINDOW";
25 |
26 | private PropertiesConfiguration lastEventConfiguration;
27 |
28 | //being used in tests to override value from properties
29 | private Integer issueHistoryWindowInMinutes;
30 |
31 | private EventProcessorConfiguration() {
32 | initialize();
33 | }
34 |
35 | private void initialize() throws ConfigurationAccessError {
36 | try {
37 | File dataDir = new File(ConfigurationFactory.get().getString("PROP.APP_DATA_DIR", "."));
38 | File file = new File(dataDir, "lastEvent.properties");
39 | logger.info("Going to load properties from file {}", file.getAbsolutePath());
40 | lastEventConfiguration = new PropertiesConfiguration(file);
41 | logger.info("Initialized EventProcessorConfiguration");
42 | } catch (ConfigurationException e) {
43 | throw new ConfigurationAccessError("Failed to access properties", e);
44 | }
45 | }
46 |
47 |
48 | public static EventProcessorConfiguration instance() {
49 | return instance;
50 | }
51 |
52 | /**
53 | * @return last event processed (issue) or null if not available for specified project
54 | */
55 | public Date loadLastProcessedDate(String project) {
56 | Long lastEventDate = lastEventConfiguration.getLong(getProjectLastEventDateKey(project), null);
57 | if (lastEventDate != null && lastEventDate > 0) {
58 | return new Date(lastEventDate);
59 | }
60 | return null;
61 | }
62 |
63 |
64 |
65 | public void saveLastProcessedEventDate(Date lastProcessedEventDate, String project) throws ConfigurationException {
66 | Date currentLastProcessedDate = loadLastProcessedDate(project);
67 | if (currentLastProcessedDate == null || currentLastProcessedDate.before(lastProcessedEventDate)) {
68 | lastEventConfiguration.setProperty(getProjectLastEventDateKey(project), lastProcessedEventDate.getTime());
69 | lastEventConfiguration.save();
70 | }
71 | }
72 |
73 | private String getProjectLastEventDateKey(String project) {
74 | return LAST_EVENT_DATE+"."+project;
75 | }
76 |
77 | /**
78 | * "maximum-allowed window" defined/configured by the configuration property PROP.ISSUE_HISTORY_WINDOW
79 | * @param date date
80 | * @return date if it is after the "maximum-allowed window" or date which define the lower bound of "maximum-allowed window"
81 | */
82 | public Date resolveMinimumAllowedDate(Date date) {
83 | Date oldestDateInThePast = oldestDateInThePast();
84 | if (date == null) {
85 | return oldestDateInThePast;
86 | } else if (date.before(oldestDateInThePast)) {
87 | return oldestDateInThePast;
88 | }
89 | return date;
90 | }
91 |
92 | /**
93 | * @return Date in the past - N minutes before now. Where N - defined by the property "PROP.ISSUE_HISTORY_WINDOW"
94 | */
95 | public Date oldestDateInThePast() {
96 | return new DateBuilder().addMinutes(-getIssueHistoryWindowInMinutes()).build();
97 | }
98 |
99 | public void clear() throws ConfigurationException {
100 | lastEventConfiguration.clear();
101 | lastEventConfiguration.save();
102 | }
103 |
104 | public int getIssueHistoryWindowInMinutes() {
105 | //3 days by default
106 | if (issueHistoryWindowInMinutes == null) {
107 | return ConfigurationFactory.get().getInt(PROP_ISSUE_HISTORY_WINDOW, 60 * 24 * 3);
108 | }
109 | return issueHistoryWindowInMinutes;
110 | }
111 |
112 | /**
113 | * Disposes itself and re-initializes
114 | */
115 | public void reload() {
116 | initialize();
117 | }
118 |
119 | public void setIssueHistoryWindowInMinutes(int issueHistoryWindowInMinutes) {
120 | this.issueHistoryWindowInMinutes = issueHistoryWindowInMinutes;
121 | }
122 | }
123 |
--------------------------------------------------------------------------------
/src/test/resources/feeds/issue-changes-t1-t3.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | DMAN
6 |
7 |
8 | 584
9 |
10 |
11 | Display major events (Equinox / Solstice)
12 |
13 |
14 | 1404394803932
15 |
16 |
17 | 1409073351938
18 |
19 |
20 | nikolay.chorniy@gmail.com
21 |
22 |
23 | Nikolay Chorniy
24 |
25 |
26 | noura
27 |
28 |
29 | Noura Hassan
30 |
31 |
32 | 0
33 |
34 |
35 | 0
36 |
37 |
38 | DMAN-573
39 |
40 |
41 | Normal
42 |
43 |
44 | Task
45 |
46 |
47 | Reopened
48 |
49 |
50 | No subsystem
51 |
52 |
53 | 1.0.23
54 |
55 | Star
56 |
57 |
58 |
59 | rob
60 |
61 |
62 | 1404927516756
63 |
64 |
65 | 0.1.13
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 | rob
83 |
84 |
85 | 1406227765001
86 |
87 |
88 | 0.1.16
89 | 0.1.19
90 |
91 |
92 |
--------------------------------------------------------------------------------
/src/main/java/com/ontometrics/integrations/jobs/EventListenerImpl.java:
--------------------------------------------------------------------------------
1 | package com.ontometrics.integrations.jobs;
2 |
3 | import com.ontometrics.integrations.configuration.ChatServer;
4 | import com.ontometrics.integrations.configuration.ConfigurationFactory;
5 | import com.ontometrics.integrations.configuration.EventProcessorConfiguration;
6 | import com.ontometrics.integrations.configuration.YouTrackInstanceFactory;
7 | import com.ontometrics.integrations.events.IssueEditSession;
8 | import com.ontometrics.integrations.sources.EditSessionsExtractor;
9 | import com.ontometrics.integrations.sources.StreamProvider;
10 | import org.apache.commons.configuration.Configuration;
11 | import org.slf4j.Logger;
12 | import org.slf4j.LoggerFactory;
13 |
14 | import java.util.*;
15 | import java.util.concurrent.atomic.AtomicInteger;
16 |
17 |
18 | /**
19 | * Created on 8/18/14.
20 | *
21 | */
22 | public class EventListenerImpl implements EventListener {
23 | private static final Logger log = LoggerFactory.getLogger(EventListenerImpl.class);
24 |
25 | private static final Comparator CREATED_TIME_COMPARATOR = new Comparator() {
26 | @Override
27 | public int compare(IssueEditSession s1, IssueEditSession s2) {
28 | return s1.getUpdated().compareTo(s2.getUpdated());
29 | }
30 | };
31 |
32 | private ChatServer chatServer;
33 |
34 | private ProjectProvider projectProvider;
35 |
36 | private EditSessionsExtractor editSessionsExtractor;
37 |
38 | /**
39 | * @param feedStreamProvider feed resource provider
40 | */
41 | public EventListenerImpl(StreamProvider feedStreamProvider, ChatServer chatServer) {
42 | this(createEditSessionExtractor(feedStreamProvider));
43 | this.chatServer = chatServer;
44 | }
45 |
46 | private static EditSessionsExtractor createEditSessionExtractor(StreamProvider feedStreamProvider) {
47 | if(feedStreamProvider == null) {
48 | throw new IllegalArgumentException("You must provide feedStreamProvider.");
49 | }
50 | Configuration configuration = ConfigurationFactory.get();
51 | return new EditSessionsExtractor(YouTrackInstanceFactory.createYouTrackInstance(configuration), feedStreamProvider);
52 | }
53 |
54 | /**
55 | * @param editSessionsExtractor editSessionsExtractor
56 | */
57 | public EventListenerImpl(EditSessionsExtractor editSessionsExtractor) {
58 | if(editSessionsExtractor == null ) {
59 | throw new IllegalArgumentException("You must provide sourceURL and chatServer.");
60 | }
61 | this.editSessionsExtractor = editSessionsExtractor;
62 | this.projectProvider = new ProjectProvider(editSessionsExtractor.getIssueTracker(),
63 | editSessionsExtractor.getStreamProvider());
64 | }
65 |
66 | /**
67 | *
68 | * On wake, the job of this agent is simply to get any edits that have occurred since its last run from
69 | * the ticketing system (using the {@link com.ontometrics.integrations.sources.EditSessionsExtractor} and
70 | * then post them to the {@link com.ontometrics.integrations.configuration.ChatServer}.
71 | *
72 | *
73 | * This should stay simple: if we can't process a session for any reason we should skip it.
74 | *
10 | * Represents a single User (updater) making one or more changes to
11 | * an {@link com.ontometrics.integrations.events.Issue} at a given
12 | * time.
13 | *
14 | *
15 | * User: Rob
16 | * Date: 8/23/14
17 | * Time: 7:25 PM
18 | *
19 | * (c) ontometrics 2014, All Rights Reserved
20 | */
21 | public class IssueEditSession {
22 |
23 | private final Issue issue;
24 | private final String updater;
25 | private final List changes;
26 | private final Comment comment;
27 | private final Date updated;
28 | private final List attachments;
29 | private final List links;
30 |
31 | public IssueEditSession(Builder builder) {
32 | issue = builder.issue;
33 | updater = builder.updater;
34 | updated = builder.updated;
35 | changes = new ArrayList<>(builder.changes);
36 | comment = builder.comment;
37 | attachments = new ArrayList<>(builder.attachments);
38 | links = new ArrayList<>(builder.links);
39 | }
40 |
41 | public IssueEditSession removeAttachments() {
42 | attachments.clear();
43 | return this;
44 | }
45 |
46 | public static class Builder {
47 |
48 | private Issue issue;
49 | private String updater;
50 | private Date updated;
51 | private List changes = Collections.emptyList();
52 | private Comment comment;
53 | private List attachments = Collections.emptyList();
54 | private List links = Collections.emptyList();
55 |
56 | public Builder issue(Issue issue){
57 | this.issue = issue;
58 | return this;
59 | }
60 |
61 | public Builder updater(String updater){
62 | this.updater = updater;
63 | return this;
64 | }
65 |
66 | public Builder updated(Date updated){
67 | this.updated = updated;
68 | return this;
69 | }
70 |
71 | public Builder changes(List changes){
72 | this.changes = changes;
73 | return this;
74 | }
75 |
76 | public Builder comment(Comment comment){
77 | this.comment = comment;
78 | return this;
79 | }
80 |
81 | public Builder attachments(List attachments){
82 | this.attachments = attachments;
83 | return this;
84 | }
85 |
86 | public Builder links(List links){
87 | this.links = links;
88 | return this;
89 | }
90 |
91 | public IssueEditSession build(){
92 | return new IssueEditSession(this);
93 | }
94 |
95 |
96 | }
97 |
98 | public Issue getIssue() {
99 | return issue;
100 | }
101 |
102 | public String getUpdater() {
103 | return updater;
104 | }
105 |
106 | public Date getUpdated() {
107 | return updated;
108 | }
109 |
110 | /**
111 | * The changes that were made in this session.
112 | *
113 | * @return all edits made by the updater in this session
114 | */
115 | public List getChanges() {
116 | return changes;
117 | }
118 |
119 | public Comment getComment() {
120 | return comment;
121 | }
122 |
123 | public List getAttachments() {
124 | return attachments;
125 | }
126 |
127 | public List getLinks() {
128 | return links;
129 | }
130 |
131 | public boolean isCreationEdit(){
132 | return getIssue().getCreated()!=null && !getIssue().getCreator().isEmpty() && ((getUpdated().getTime()-getIssue().getCreated().getTime())/(1000*60*60) < 5);
133 | }
134 |
135 | public boolean hasChanges(){
136 | return getChanges().size() > 0 || (getComment() != null && !getComment().isDeleted());
137 | }
138 |
139 | @Override
140 | public boolean equals(Object o) {
141 | if (this == o) return true;
142 | if (o == null || getClass() != o.getClass()) return false;
143 |
144 | IssueEditSession that = (IssueEditSession) o;
145 |
146 | return issue.equals(that.issue) && updated.equals(that.updated) && !(updater != null ? !updater.equals(that.updater) : that.updater != null);
147 |
148 | }
149 |
150 | @Override
151 | public int hashCode() {
152 | int result = issue.hashCode();
153 | result = 31 * result + (updater != null ? updater.hashCode() : 0);
154 | result = 31 * result + updated.hashCode();
155 | return result;
156 | }
157 |
158 | @Override
159 | public String toString() {
160 | StringBuilder b = new StringBuilder(issue.toString());
161 | b.append(String.format(" %s ", updater));
162 | int changeCounter = 0;
163 | for (IssueEdit edit : changes){
164 | b.append(edit.toString());
165 | if (changeCounter++ < changes.size()-1){
166 | b.append(", ");
167 | }
168 | }
169 | if (getComment()!=null){
170 | b.append(comment.toString()).append(System.lineSeparator());
171 | }
172 | for (AttachmentEvent attachmentEvent : attachments){
173 | b.append(attachmentEvent.toString()).append(System.lineSeparator());
174 | }
175 | for (IssueLink link : links){
176 | b.append(link.toString()).append(System.lineSeparator());
177 | }
178 | return b.toString();
179 | }
180 |
181 | }
182 |
--------------------------------------------------------------------------------
/src/test/resources/feeds/issue-with-comments-ap-22.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | AP
6 |
7 |
8 | 22
9 |
10 |
11 | Agents must be able to generate Proposals
12 |
13 |
14 | This is the beginning of the road to a Contract.
15 |
16 | The template will be merged with the basic information that has been gathered.
17 |
18 | We need to think about how we could potentially keep a few options in orbit, gracefully. It's very
19 | possible someone might ask for a few proposals and then decide to go with one of them.
20 |
21 | The basic information needed at the time of proposal is laid out in the requirements; there are few
22 | surprises:
23 |
24 | * Customer (Company)
25 | * Talent
26 | * Fees
27 | * Event information
28 |
29 | Merging the results of these input variables and the template produces the Proposal.
30 |
31 |
32 |
33 | 1407425265769
34 |
35 |
36 | 1415631357818
37 |
38 |
39 | nikolay.chorniy@gmail.com
40 |
41 |
42 | Nikolay Chorniy
43 |
44 |
45 | rob
46 |
47 |
48 | Rob Williams
49 |
50 |
51 | 2
52 |
53 |
54 | 0
55 |
56 |
57 | AP-23
58 |
59 |
60 | Normal
61 | Normal
62 |
63 | #ebf4dd
64 | #64992C
65 |
66 |
67 |
68 | Story
69 | Story
70 |
71 |
72 | In Progress
73 | In Progress
74 |
75 |
76 | No subsystem
77 |
78 |
79 | 0.0.6
80 |
81 |
84 |
85 |
86 |
88 |
89 |
90 | Star
91 |
92 |
93 |
94 | rob
95 |
96 |
97 | 1407425449468
98 |
99 |
100 | AP-23
101 |
102 |
103 |
104 |
105 | rob
106 |
107 |
108 | 1414179732983
109 |
110 |
111 | Bug
112 | Story
113 |
114 |
115 | 0.0.4
116 |
117 |
118 |
119 |
120 | andrey.chorniy
121 |
122 |
123 | 1414516357474
124 |
125 |
126 | Submitted
127 | In Progress
128 |
129 |
130 |
131 |
132 | rob
133 |
134 |
135 | 1414789266374
136 |
137 |
138 | 0.0.4
139 | 0.0.5
140 |
141 |
142 |
143 |
144 | rob
145 |
146 |
147 | 1415508872698
148 |
149 |
150 | 0.0.5
151 | 0.0.6
152 |
153 |
154 |
--------------------------------------------------------------------------------
/src/main/java/com/ontometrics/integrations/youtrack/YouTrackImageServlet.java:
--------------------------------------------------------------------------------
1 | package com.ontometrics.integrations.youtrack;
2 |
3 | import com.ontometrics.db.MapDb;
4 | import com.ontometrics.integrations.configuration.ConfigurationFactory;
5 | import com.ontometrics.integrations.configuration.StreamProviderFactory;
6 | import com.ontometrics.integrations.configuration.YouTrackInstance;
7 | import com.ontometrics.integrations.configuration.YouTrackInstanceFactory;
8 | import com.ontometrics.integrations.sources.InputStreamHandler;
9 | import com.ontometrics.integrations.sources.StreamProvider;
10 | import org.apache.commons.configuration.Configuration;
11 | import org.apache.commons.io.IOUtils;
12 | import org.apache.commons.lang.StringUtils;
13 | import org.apache.http.client.methods.HttpGet;
14 | import org.slf4j.Logger;
15 | import org.slf4j.LoggerFactory;
16 |
17 | import javax.servlet.ServletException;
18 | import javax.servlet.http.HttpServlet;
19 | import javax.servlet.http.HttpServletRequest;
20 | import javax.servlet.http.HttpServletResponse;
21 | import java.io.IOException;
22 | import java.io.InputStream;
23 | import java.net.MalformedURLException;
24 | import java.net.URL;
25 | import java.net.URLEncoder;
26 | import java.nio.charset.Charset;
27 |
28 | /**
29 | *
30 | */
31 | public class YouTrackImageServlet extends HttpServlet {
32 | private static final Logger logger = LoggerFactory.getLogger(YouTrackImageServlet.class);
33 |
34 | private YouTrackInstance youTrackInstance;
35 | /**
36 | * Attachment URL example "http://issuetracker.com/_persistent/image.png?file=78-496"
37 | */
38 | private StreamProvider streamProvider;
39 |
40 | @Override
41 | public void init() throws ServletException {
42 | super.init();
43 | final Configuration configuration = ConfigurationFactory.get();
44 |
45 | youTrackInstance = YouTrackInstanceFactory.createYouTrackInstance(configuration);
46 | streamProvider = StreamProviderFactory.createStreamProvider(configuration);
47 |
48 | }
49 |
50 | @Override
51 | protected void service(HttpServletRequest servletRequest, final HttpServletResponse servletResponse)
52 | throws ServletException, IOException {
53 | if (!servletRequest.getMethod().equals(HttpGet.METHOD_NAME)){
54 | servletResponse.sendError(HttpServletResponse.SC_BAD_REQUEST, "Only GET method supported");
55 | return;
56 | }
57 |
58 | String imageId = servletRequest.getParameter("rid");
59 | if (StringUtils.isBlank(imageId)){
60 | servletResponse.sendError(HttpServletResponse.SC_BAD_REQUEST, "Resource id not specified");
61 | return;
62 | }
63 |
64 | URL youTrackUrl = resolveYouTrackAttachmentUri(imageId);
65 | logger.debug("Generated URI {0}", youTrackUrl);
66 |
67 | try {
68 | String name = servletRequest.getParameter("name");
69 | if (StringUtils.isNotBlank(name)) {
70 | servletResponse.addHeader("Content-Disposition", buildContentDisposition(CONTENT_DISPOSITION_ATTACHMENT, name, Charset.forName("UTF-8")));
71 | }
72 | streamProvider.openResourceStream(youTrackUrl, new InputStreamHandler() {
73 | @Override
74 | public Void handleStream(InputStream is, int responseCode) throws Exception {
75 | servletResponse.setStatus(responseCode);
76 | copyStream(is, servletResponse);
77 | return null;
78 | }
79 | });
80 | } catch (Exception e) {
81 | logger.error("Failed to process request", e);
82 | servletResponse.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
83 | }
84 |
85 | }
86 |
87 | private void copyStream(InputStream is, HttpServletResponse servletResponse) {
88 | //TODO we may generate thumbnail here if needed, for now we just stream it back
89 | try {
90 | IOUtils.copy(is, servletResponse.getOutputStream());
91 | } catch (IOException e) {
92 | logger.error("Failed to write response", e);
93 | }
94 | }
95 |
96 |
97 | private URL resolveYouTrackAttachmentUri(String externalImageId) throws MalformedURLException {
98 | String youTrackImageId = resolveYouTrackImageId(externalImageId);
99 | if (StringUtils.isBlank(externalImageId)) {
100 | throw new RuntimeException("Image not found");
101 | }
102 | return new URL(String.format("%s/_persistent/%s?file=%s", youTrackInstance.getBaseUrl(), "image.png",
103 | youTrackImageId));
104 | }
105 |
106 | @SuppressWarnings("unused")
107 | private String resolveYouTrackImageId(String externalImageId) {
108 | return MapDb.instance().getAttachmentMap().get(externalImageId);
109 | }
110 |
111 |
112 | public static final String CONTENT_DISPOSITION_INLINE = "inline";
113 | public static final String CONTENT_DISPOSITION_ATTACHMENT = "attachment";
114 |
115 | /**
116 | * Set the (new) value of the {@code Content-Disposition} header
117 | * for {@code main body}, optionally encoding the filename using the RFC 5987.
118 | *
Only the US-ASCII, UTF-8 and ISO-8859-1 charsets are supported.
119 | *
120 | * @param type content disposition type
121 | * @param filename the filename (may be {@code null})
122 | * @param charset the charset used for the filename (may be {@code null})
123 | * @see RFC 7230 Section 3.2.4
124 | * @since 4.3.3
125 | */
126 | private static String buildContentDisposition(String type, String filename, Charset charset){
127 |
128 | if (!CONTENT_DISPOSITION_INLINE.equals(type) && !CONTENT_DISPOSITION_ATTACHMENT.equals(type)) {
129 | throw new IllegalArgumentException("type must be inline or attachment");
130 | }
131 |
132 | StringBuilder builder = new StringBuilder(type);
133 | if (filename != null) {
134 | builder.append("; ");
135 |
136 | if (charset == null || charset.name().equals("US-ASCII")) {
137 | builder.append("filename=\"");
138 | builder.append(filename).append('\"');
139 | } else {
140 | builder.append("filename*=");
141 | builder.append(encodeHeaderFieldParam(filename, charset));
142 | }
143 | }
144 | return builder.toString();
145 | }
146 |
147 | /**
148 | * Copied from Spring {@link org.springframework.http.HttpHeaders}
149 | *
150 | * Encode the given header field param as describe in RFC 5987.
151 | *
152 | * @param input the header field param
153 | * @param charset the charset of the header field param string
154 | * @return the encoded header field param
155 | * @see RFC 5987
156 | */
157 | private static String encodeHeaderFieldParam(String input, Charset charset) {
158 | if (charset.name().equals("US-ASCII")) {
159 | return input;
160 | }
161 | byte[] source = input.getBytes(charset);
162 | int len = source.length;
163 | StringBuilder sb = new StringBuilder(len << 1);
164 | sb.append(charset.name());
165 | sb.append("''");
166 | for (byte b : source) {
167 | if (isRFC5987AttrChar(b)) {
168 | sb.append((char) b);
169 | } else {
170 | sb.append('%');
171 | char hex1 = Character.toUpperCase(Character.forDigit((b >> 4) & 0xF, 16));
172 | char hex2 = Character.toUpperCase(Character.forDigit(b & 0xF, 16));
173 | sb.append(hex1);
174 | sb.append(hex2);
175 | }
176 | }
177 | return sb.toString();
178 | }
179 |
180 | /**
181 | * Copied from Spring {@link org.springframework.http.HttpHeaders}
182 | */
183 | private static boolean isRFC5987AttrChar(byte c) {
184 | return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
185 | c == '!' || c == '#' || c == '$' || c == '&' || c == '+' || c == '-' ||
186 | c == '.' || c == '^' || c == '_' || c == '`' || c == '|' || c == '~';
187 | }
188 | }
189 |
--------------------------------------------------------------------------------