1 /*
2  * Copyright (c) 2017 Google Inc. All Rights Reserved.
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License"); you
5  * may not use this file except in compliance with the License. You may
6  * obtain a copy of the License at
7  *
8  *     http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
13  * implied. See the License for the specific language governing
14  * permissions and limitations under the License.
15  */
16 
17 package com.android.vts.job;
18 
19 import static com.googlecode.objectify.ObjectifyService.ofy;
20 
21 import com.android.vts.entity.CodeCoverageEntity;
22 import com.android.vts.entity.DeviceInfoEntity;
23 import com.android.vts.entity.TestCoverageStatusEntity;
24 import com.android.vts.entity.TestRunEntity;
25 import com.android.vts.util.EmailHelper;
26 import com.google.appengine.api.datastore.DatastoreService;
27 import com.google.appengine.api.datastore.DatastoreServiceFactory;
28 import com.google.appengine.api.datastore.Entity;
29 import com.google.appengine.api.datastore.EntityNotFoundException;
30 import com.google.appengine.api.datastore.Key;
31 import com.google.appengine.api.datastore.KeyFactory;
32 import com.google.appengine.api.datastore.Query;
33 import com.google.appengine.api.taskqueue.Queue;
34 import com.google.appengine.api.taskqueue.QueueFactory;
35 import com.google.appengine.api.taskqueue.TaskOptions;
36 import java.io.IOException;
37 import java.io.UnsupportedEncodingException;
38 import java.math.RoundingMode;
39 import java.text.DecimalFormat;
40 import java.util.ArrayList;
41 import java.util.HashSet;
42 import java.util.List;
43 import java.util.Set;
44 import java.util.logging.Level;
45 import java.util.logging.Logger;
46 import javax.mail.Message;
47 import javax.mail.MessagingException;
48 import javax.servlet.http.HttpServletRequest;
49 import javax.servlet.http.HttpServletResponse;
50 import org.apache.commons.lang.StringUtils;
51 
52 /**
53  * Coverage notification job.
54  */
55 public class VtsCoverageAlertJobServlet extends BaseJobServlet {
56 
57   private static final String COVERAGE_ALERT_URL = "/task/vts_coverage_job";
58   protected static final Logger logger =
59       Logger.getLogger(VtsCoverageAlertJobServlet.class.getName());
60   protected static final double CHANGE_ALERT_THRESHOLD = 0.05;
61   protected static final double GOOD_THRESHOLD = 0.7;
62   protected static final double BAD_THRESHOLD = 0.3;
63 
64   protected static final DecimalFormat FORMATTER;
65 
66   /** Initialize the decimal formatter. */
67   static {
68     FORMATTER = new DecimalFormat("#.#");
69     FORMATTER.setRoundingMode(RoundingMode.HALF_UP);
70   }
71 
72   /**
73    * Gets a new coverage status and adds notification emails to the messages list.
74    *
75    * Send an email to notify subscribers in the event that a test goes up or down by more than 5%,
76    * becomes higher or lower than 70%, or becomes higher or lower than 30%.
77    *
78    * @param status The TestCoverageStatusEntity object for the test.
79    * @param testRunKey The key for TestRunEntity whose data to process and reflect in the state.
80    * @param link The string URL linking to the test's status table.
81    * @param emailAddresses The list of email addresses to send notifications to.
82    * @param messages The email Message queue.
83    * @returns TestCoverageStatusEntity or null if no update is available.
84    */
getTestCoverageStatus( TestCoverageStatusEntity status, Key testRunKey, String link, List<String> emailAddresses, List<Message> messages)85   public static TestCoverageStatusEntity getTestCoverageStatus(
86       TestCoverageStatusEntity status,
87       Key testRunKey,
88       String link,
89       List<String> emailAddresses,
90       List<Message> messages)
91       throws IOException {
92     DatastoreService datastore = DatastoreServiceFactory.getDatastoreService();
93 
94     String testName = status.getTestName();
95 
96     double previousPct;
97     double coveragePct;
98     if (status == null || status.getTotalLineCount() <= 0 || status.getCoveredLineCount() < 0) {
99       previousPct = 0;
100     } else {
101       previousPct = ((double) status.getCoveredLineCount()) / status.getTotalLineCount();
102     }
103 
104     Entity testRun;
105     try {
106       testRun = datastore.get(testRunKey);
107     } catch (EntityNotFoundException e) {
108       logger.log(Level.WARNING, "Test run not found: " + testRunKey);
109       return null;
110     }
111 
112     TestRunEntity testRunEntity = TestRunEntity.fromEntity(testRun);
113         if (testRunEntity == null || !testRunEntity.getHasCodeCoverage()) {
114       return null;
115     }
116     CodeCoverageEntity codeCoverageEntity = testRunEntity.getCodeCoverageEntity();
117 
118     if (codeCoverageEntity.getTotalLineCount() <= 0
119             || codeCoverageEntity.getCoveredLineCount() < 0) {
120         coveragePct = 0;
121     } else {
122         coveragePct =
123                 ((double) codeCoverageEntity.getCoveredLineCount())
124                         / codeCoverageEntity.getTotalLineCount();
125     }
126 
127     Set<String> buildIdList = new HashSet<>();
128     Query deviceQuery = new Query(DeviceInfoEntity.KIND).setAncestor(testRun.getKey());
129     List<DeviceInfoEntity> devices = new ArrayList<>();
130     for (Entity device : datastore.prepare(deviceQuery).asIterable()) {
131       DeviceInfoEntity deviceEntity = DeviceInfoEntity.fromEntity(device);
132       if (deviceEntity == null) {
133         continue;
134       }
135       devices.add(deviceEntity);
136       buildIdList.add(deviceEntity.getBuildId());
137     }
138     String deviceBuild = StringUtils.join(buildIdList, ", ");
139     String footer = EmailHelper.getEmailFooter(testRunEntity, devices, link);
140 
141     String subject = null;
142     String body = null;
143     String subjectSuffix = " @ " + deviceBuild;
144     if (coveragePct >= GOOD_THRESHOLD && previousPct < GOOD_THRESHOLD) {
145       // Coverage entered the good zone
146       subject =
147           "Congratulations! "
148               + testName
149               + " has exceeded "
150               + FORMATTER.format(GOOD_THRESHOLD * 100)
151               + "% coverage"
152               + subjectSuffix;
153       body =
154           "Hello,<br><br>The "
155               + testName
156               + " has achieved "
157               + FORMATTER.format(coveragePct * 100)
158               + "% code coverage on device build ID(s): "
159               + deviceBuild
160               + "."
161               + footer;
162     } else if (coveragePct < GOOD_THRESHOLD && previousPct >= GOOD_THRESHOLD) {
163       // Coverage dropped out of the good zone
164       subject =
165           "Warning! "
166               + testName
167               + " has dropped below "
168               + FORMATTER.format(GOOD_THRESHOLD * 100)
169               + "% coverage"
170               + subjectSuffix;
171       ;
172       body =
173           "Hello,<br><br>The test "
174               + testName
175               + " has dropped to "
176               + FORMATTER.format(coveragePct * 100)
177               + "% code coverage on device build ID(s): "
178               + deviceBuild
179               + "."
180               + footer;
181     } else if (coveragePct <= BAD_THRESHOLD && previousPct > BAD_THRESHOLD) {
182       // Coverage entered into the bad zone
183       subject =
184           "Warning! "
185               + testName
186               + " has dropped below "
187               + FORMATTER.format(BAD_THRESHOLD * 100)
188               + "% coverage"
189               + subjectSuffix;
190       body =
191           "Hello,<br><br>The test "
192               + testName
193               + " has dropped to "
194               + FORMATTER.format(coveragePct * 100)
195               + "% code coverage on device build ID(s): "
196               + deviceBuild
197               + "."
198               + footer;
199     } else if (coveragePct > BAD_THRESHOLD && previousPct <= BAD_THRESHOLD) {
200       // Coverage emerged from the bad zone
201       subject =
202           "Congratulations! "
203               + testName
204               + " has exceeded "
205               + FORMATTER.format(BAD_THRESHOLD * 100)
206               + "% coverage"
207               + subjectSuffix;
208       body =
209           "Hello,<br><br>The test "
210               + testName
211               + " has achived "
212               + FORMATTER.format(coveragePct * 100)
213               + "% code coverage on device build ID(s): "
214               + deviceBuild
215               + "."
216               + footer;
217     } else if (coveragePct - previousPct < -CHANGE_ALERT_THRESHOLD) {
218       // Send a coverage drop alert
219       subject =
220           "Warning! "
221               + testName
222               + "'s code coverage has decreased by more than "
223               + FORMATTER.format(CHANGE_ALERT_THRESHOLD * 100)
224               + "%"
225               + subjectSuffix;
226       body =
227           "Hello,<br><br>The test "
228               + testName
229               + " has dropped from "
230               + FORMATTER.format(previousPct * 100)
231               + "% code coverage to "
232               + FORMATTER.format(coveragePct * 100)
233               + "% code coverage on device build ID(s): "
234               + deviceBuild
235               + "."
236               + footer;
237     } else if (coveragePct - previousPct > CHANGE_ALERT_THRESHOLD) {
238       // Send a coverage improvement alert
239       subject =
240           testName
241               + "'s code coverage has increased by more than "
242               + FORMATTER.format(CHANGE_ALERT_THRESHOLD * 100)
243               + "%"
244               + subjectSuffix;
245       body =
246           "Hello,<br><br>The test "
247               + testName
248               + " has increased from "
249               + FORMATTER.format(previousPct * 100)
250               + "% code coverage to "
251               + FORMATTER.format(coveragePct * 100)
252               + "% code coverage on device build ID(s): "
253               + deviceBuild
254               + "."
255               + footer;
256     }
257     if (subject != null && body != null) {
258       try {
259         messages.add(EmailHelper.composeEmail(emailAddresses, subject, body));
260       } catch (MessagingException | UnsupportedEncodingException e) {
261         logger.log(Level.WARNING, "Error composing email : ", e);
262       }
263     }
264         return new TestCoverageStatusEntity(
265                 testName,
266                 testRunEntity.getStartTimestamp(),
267                 codeCoverageEntity.getCoveredLineCount(),
268                 codeCoverageEntity.getTotalLineCount(),
269                 devices.size() > 0 ? devices.get(0).getId() : 0);
270   }
271 
272   /**
273    * Add a task to process coverage data
274    *
275    * @param testRunKey The key of the test run whose data process.
276    */
addTask(Key testRunKey)277   public static void addTask(Key testRunKey) {
278     Queue queue = QueueFactory.getDefaultQueue();
279     String keyString = KeyFactory.keyToString(testRunKey);
280     queue.add(
281         TaskOptions.Builder.withUrl(COVERAGE_ALERT_URL)
282             .param("runKey", keyString)
283             .method(TaskOptions.Method.POST));
284   }
285 
286   @Override
doPost(HttpServletRequest request, HttpServletResponse response)287   public void doPost(HttpServletRequest request, HttpServletResponse response)
288       throws IOException {
289     String runKeyString = request.getParameter("runKey");
290 
291     Key testRunKey;
292     try {
293       testRunKey = KeyFactory.stringToKey(runKeyString);
294     } catch (IllegalArgumentException e) {
295       logger.log(Level.WARNING, "Invalid key specified: " + runKeyString);
296       return;
297     }
298     String testName = testRunKey.getParent().getName();
299 
300     TestCoverageStatusEntity status = ofy().load().type(TestCoverageStatusEntity.class).id(testName)
301         .now();
302     if (status == null) {
303             status = new TestCoverageStatusEntity(testName, 0, -1, -1, 0);
304     }
305 
306     StringBuffer fullUrl = request.getRequestURL();
307     String baseUrl = fullUrl.substring(0, fullUrl.indexOf(request.getRequestURI()));
308     String link = baseUrl + "/show_tree?testName=" + testName;
309     TestCoverageStatusEntity newStatus;
310     List<Message> messageQueue = new ArrayList<>();
311     try {
312       List<String> emails = EmailHelper.getSubscriberEmails(testRunKey.getParent());
313       newStatus = getTestCoverageStatus(status, testRunKey, link, emails, messageQueue);
314     } catch (IOException e) {
315       logger.log(Level.SEVERE, e.toString());
316       return;
317     }
318 
319     if (newStatus == null) {
320       return;
321     } else {
322       if (status == null || status.getUpdatedTimestamp() < newStatus.getUpdatedTimestamp()) {
323         newStatus.save();
324         EmailHelper.sendAll(messageQueue);
325       }
326     }
327   }
328 }
329