001    package org.hackystat.sensor.ant.svn;
002    
003    import java.text.ParseException;
004    import java.text.SimpleDateFormat;
005    import java.util.Date;
006    import java.util.HashMap;
007    import java.util.Locale;
008    import java.util.Map;
009    
010    import org.apache.tools.ant.BuildException;
011    import org.apache.tools.ant.Task;
012    import org.hackystat.sensorshell.SensorShellProperties;
013    import org.hackystat.sensorshell.SensorShellException;
014    import org.hackystat.sensorshell.SensorShell;
015    import org.hackystat.sensorshell.usermap.SensorShellMap;
016    import org.hackystat.sensorshell.usermap.SensorShellMapException;
017    import org.hackystat.utilities.time.period.Day;
018    import org.hackystat.utilities.tstamp.Tstamp;
019    import org.hackystat.utilities.tstamp.TstampSet;
020    
021    /**
022     * Ant task to extract the svn commits and send those information to Hackystat
023     * server. Note: For binary files, the values for lines addes, lines deleted,
024     * and total lines are meaningless.
025     * 
026     * @author Qin ZHANG
027     * @author Austen Ito (v8 port)
028     */
029    public class SvnSensor extends Task {
030      private String repositoryName;
031      private String repositoryUrl;
032      private String userName;
033      private String password;
034      private String fileNamePrefix;
035      private String defaultHackystatAccount = "";
036      private String defaultHackystatPassword = "";
037      private String defaultHackystatSensorbase = "";
038      private SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd", Locale.US);
039      private String fromDateString, toDateString;
040      private Date fromDate, toDate;
041      private boolean isVerbose = false;
042      private String tool = "svn";
043      private String lastIntervalInMinutesString = "";
044      private int lastIntervalInMinutes;
045    
046      /**
047       * Sets the svn repository name. This name can be any string. It's used in
048       * commit metric to identify from which svn repository metric is retrieved.
049       * 
050       * @param repositoryName The name of the svn repository.
051       */
052      public void setRepositoryName(String repositoryName) {
053        this.repositoryName = repositoryName;
054      }
055    
056      /**
057       * Sets the url to the svn repository. It can points to any subdirectory in
058       * the repository. However, note that this sensor only supports http|https|svn
059       * protocol.
060       * 
061       * @param repositoryUrl The url to the svn repository.
062       */
063      public void setRepositoryUrl(String repositoryUrl) {
064        this.repositoryUrl = repositoryUrl;
065      }
066    
067      /**
068       * Sets the user name to access the SVN repository. If not set, then anonymous
069       * credential is used.
070       * 
071       * @param userName The user name.
072       */
073      public void setUserName(String userName) {
074        this.userName = userName;
075      }
076    
077      /**
078       * Sets if verbose mode has been enabled.
079       * @param isVerbose true if verbose mode is enabled, false if not.
080       */
081      public void setVerbose(boolean isVerbose) {
082        this.isVerbose = isVerbose;
083      }
084    
085      /**
086       * Sets the password for the user name.
087       * 
088       * @param password The password.
089       */
090      public void setPassword(String password) {
091        this.password = password;
092      }
093    
094      /**
095       * Sets a string to be prepended to the file path in commit metric. Recall
096       * that svn sensor can only get relative file path to the svn repository root,
097       * however, most hackystat analysis requires fully qualified file path. This
098       * prefix can be used to turn relative file path into some pseudo fully
099       * qualified file path.
100       * 
101       * @param fileNamePrefix The string to be prepended to the file path in commit
102       * metric.
103       */
104      public void setFileNamePrefix(String fileNamePrefix) {
105        this.fileNamePrefix = fileNamePrefix;
106      }
107    
108      /**
109       * Sets a default Hackystat account to which to send commit data when there is
110       * no svn committer to Hackystat account mapping.
111       * 
112       * @param defaultHackystatAccount The default Hackystat account.
113       */
114      public void setDefaultHackystatAccount(String defaultHackystatAccount) {
115        this.defaultHackystatAccount = defaultHackystatAccount;
116      }
117    
118      /**
119       * Sets the default Hackystat account password.
120       * @param defaultHackystatPassword the default account password.
121       */
122      public void setDefaultHackystatPassword(String defaultHackystatPassword) {
123        this.defaultHackystatPassword = defaultHackystatPassword;
124      }
125    
126      /**
127       * Sets the default Hackystat sensorbase server.
128       * @param defaultHackystatSensorbase the default sensorbase server.
129       */
130      public void setDefaultHackystatSensorbase(String defaultHackystatSensorbase) {
131        this.defaultHackystatSensorbase = defaultHackystatSensorbase;
132      }
133    
134      /**
135       * Sets the optional fromDate. If fromDate is set, toDate must be set. This
136       * field must be conform to yyyy-MM-dd format.
137       * 
138       * @param fromDateString The first date from which we send commit information
139       * to Hackystat server.
140       */
141      public void setFromDate(String fromDateString) {
142        this.fromDateString = fromDateString;
143      }
144    
145      /**
146       * Sets the optional toDate. If toDate is set, fromDate must be set. This
147       * field must be conform to yyyy-MM-dd format.
148       * 
149       * @param toDateString The last date to which we send commit information to
150       * Hackystat server.
151       */
152      public void setToDate(String toDateString) {
153        this.toDateString = toDateString;
154      }
155      
156      /**
157       * Sets the last interval in minutes. 
158       * 
159       * @param lastIntervalInMinutes The preceding interval in minutes to poll.  
160       */
161      public void setLastIntervalInMinutes(String lastIntervalInMinutes) {
162        this.lastIntervalInMinutesString = lastIntervalInMinutes;
163      }
164    
165      /**
166       * Checks and make sure all properties are set up correctly.
167       * 
168       * @throws BuildException If any error is detected in the property setting.
169       */
170      private void validateProperties() throws BuildException {
171        if (this.repositoryName == null || this.repositoryName.length() == 0) {
172          throw new BuildException("Attribute 'repositoryName' must be set.");
173        }
174        if (this.repositoryUrl == null || this.repositoryUrl.length() == 0) {
175          throw new BuildException("Attribute 'repositoryUrl' must be set.");
176        }
177    
178        // If lastIntervalInMinutes is set, then we define fromDate and toDate appropriately and return.
179        if (!this.lastIntervalInMinutesString.equals("")) {
180          try {
181            this.lastIntervalInMinutes = Integer.parseInt(this.lastIntervalInMinutesString);
182            long now = (new Date()).getTime();
183            this.toDate = new Date(now);
184            long intervalMillis = 1000L * 60 * this.lastIntervalInMinutes;
185            this.fromDate = new Date(now - intervalMillis);
186            return;
187          }
188          catch (Exception e) {
189            throw new BuildException("Attribute 'lastIntervalInMinutes' must be an integer.", e);
190          }
191        }
192    
193        // If lastIntervalInMinutes, fromDate, and toDate not set, we extract commit information for
194        // the previous 25 hours. (This ensures that running the sensor as part of a daily build
195        // should have enough "overlap" to not miss any entries.)
196        // Then return.
197        if (this.fromDateString == null && this.toDateString == null) {
198          long now = (new Date()).getTime();
199          this.toDate = new Date(now);
200          long twentyFiveHoursMillis = 1000 * 60 * 60 * 25;
201          this.fromDate = new Date(now - twentyFiveHoursMillis);
202          return;
203        }
204    
205        // Finally, we try to deal with the user provided from and to dates.
206        try {
207          if (this.hasSetToAndFromDates()) {
208            this.fromDate = new Date(Day.getInstance(this.dateFormat.parse(this.fromDateString))
209                .getFirstTickOfTheDay() - 1);
210            this.toDate = new Date(Day.getInstance(this.dateFormat.parse(this.toDateString))
211                .getLastTickOfTheDay());
212          }
213          else {
214            throw new BuildException(
215                "Attributes 'fromDate' and 'toDate' must either be both set or both not set.");
216          }
217        }
218        catch (ParseException ex) {
219          throw new BuildException("Unable to parse 'fromDate' or 'toDate'.", ex);
220        }
221    
222        if (this.fromDate.compareTo(this.toDate) > 0) {
223          throw new BuildException("Attribute 'fromDate' must be a date before 'toDate'.");
224        }
225    
226      }
227    
228      /**
229       * Returns true if both of the to and from date strings have been set by the client. Both dates
230       * must be set or else this sensor will not know which revisions to grab commit information.
231       * 
232       * @return true if both the to and from date strings have been set.
233       */
234      private boolean hasSetToAndFromDates() {
235        return (this.fromDateString != null) && (this.toDateString != null);
236      }
237    
238      /**
239       * Extracts commit information from SVN server, and sends them to the
240       * Hackystat server.
241       * 
242       * @throws BuildException If the task fails.
243       */
244      @Override
245      public void execute() throws BuildException {
246        this.validateProperties(); // sanity check.
247        if (this.isVerbose) {
248          System.out.printf("Processing commits for %s between %s (exclusive) and %s (inclusive)%n",
249              this.repositoryUrl, this.fromDate, this.toDate);
250        }
251    
252        try {
253          Map<String, SensorShell> shellCache = new HashMap<String, SensorShell>();
254          SensorShellMap shellMap = new SensorShellMap(this.tool);
255          if (this.isVerbose) {
256            System.out.println("Checking for user maps at: " + shellMap.getUserMapFile());
257            System.out.println("SVN accounts found: " + shellMap.getToolAccounts(this.tool));
258          }
259          
260          try {
261            shellMap.validateHackystatInfo(this.tool);
262          }
263          catch (Exception e) {
264            System.out.println("Warning: UserMap validation failed: " + e.getMessage());
265          }
266          SVNCommitProcessor processor = new SVNCommitProcessor(this.repositoryUrl, this.userName,
267              this.password);
268          long startRevision = processor.getRevisionNumber(this.fromDate) + 1;
269          long endRevision = processor.getRevisionNumber(this.toDate);
270          int entriesAdded = 0;
271          TstampSet tstampSet = new TstampSet();
272          for (long revision = startRevision; revision <= endRevision; revision++) {
273            CommitRecord commitRecord = processor.getCommitRecord(revision);
274            if (commitRecord != null) {
275              String author = commitRecord.getAuthor();
276              String message = commitRecord.getMessage();
277              Date commitTime = commitRecord.getCommitTime();
278    
279              for (CommitRecordEntry entry : commitRecord.getCommitRecordEntries()) {
280                if (this.isVerbose) {
281                  System.out.println("Retrieved SVN data: " + 
282                      commitRecord.toString() + " - " + entry.toString());
283                }
284                // Find the shell, if possible.
285                SensorShell shell = this.getShell(shellCache, shellMap, author);
286                if (shell != null) {
287                  this.processCommitEntry(shell, author, message, tstampSet
288                      .getUniqueTstamp(commitTime.getTime()), commitTime, revision, entry);
289                  entriesAdded++;
290                }
291              }
292            }
293          }
294          if (this.isVerbose) {
295            System.out.println("Found " + entriesAdded + " commit records.");
296          }
297    
298          // Send the sensor data after all entries have been processed.
299          for (SensorShell shell : shellCache.values()) {
300            if (this.isVerbose) {
301              System.out.println("Sending data to " + shell.getProperties().getSensorBaseUser() + 
302                  " at " + shell.getProperties().getSensorBaseHost());
303            }
304            shell.send();
305            shell.quit();
306          }
307        }
308        catch (Exception ex) {
309          throw new BuildException(ex);
310        }
311      }
312    
313      /**
314       * Returns the shell associated with the specified author, or null if not found. 
315       * The shellCache is
316       * used to store SensorShell instances associated with the specified user. The
317       * SensorShellMap contains the SensorShell instances built from the
318       * UserMap.xml file. This method should be used to retrieve the SensorShell
319       * instances to avoid the unnecessary creation of SensorShell instances when
320       * sending data for each commit entry. Rather than using a brand new
321       * SensorShell instance, this method finds the correct shell in the map,
322       * cache, or creates a brand new shell to use.
323       * @param shellCache the mapping of author to SensorShell.
324       * @param shellMap the mapping of author to SensorShell created by a usermap
325       * entry.
326       * @param author the author used to retrieve the shell instance.
327       * @return the shell instance associated with the author name.
328       * @throws SensorShellMapException thrown if there is a problem retrieving the
329       * shell instance.
330       * @throws SensorShellException thrown if there is a problem retrieving
331       * the Hackystat host from the v8.sensor.properties file.
332       */
333      private SensorShell getShell(Map<String, SensorShell> shellCache, SensorShellMap shellMap,
334          String author) throws SensorShellMapException, SensorShellException {
335        if (shellCache.containsKey(author)) {
336          return shellCache.get(author); // Returns a cached shell instance.
337        }
338        else {
339          // If the shell user mapping has a shell, add it to the shell cache.
340          if (shellMap.hasUserShell(author)) {
341            SensorShell shell = shellMap.getUserShell(author);
342            shellCache.put(author, shell);
343            return shell;
344          }
345          else { // Create a new shell and add it to the cache.
346            if ("".equals(this.defaultHackystatAccount)
347                || "".equals(this.defaultHackystatPassword)
348                || "".equals(this.defaultHackystatSensorbase)) {
349              System.out.println("Warning: A user mapping for the user, " + author
350                  + " was not found and no default Hackystat account login, password, "
351                  + "or server was provided. Data ignored.");
352              return null;
353            }
354            SensorShellProperties props = new SensorShellProperties(this.defaultHackystatSensorbase,
355                this.defaultHackystatAccount, this.defaultHackystatPassword);
356    
357            SensorShell shell = new SensorShell(props, false, "svn");
358            shellCache.put(author, shell);
359            return shell;
360          }
361        }
362      }
363    
364      /**
365       * Processes a commit record entry and extracts relevant metrics.
366       * 
367       * @param shell The shell that the commit record information is added to.
368       * @param author The author of the commit.
369       * @param message The commit log message.
370       * @param timestamp the unique timestamp that is associated with the specified
371       * entry.
372       * @param commitTime The commit time.
373       * @param revision The revision number.
374       * @param entry The commit record entry.
375       * 
376       * @throws Exception If there is any error.
377       */
378      private void processCommitEntry(SensorShell shell, String author, String message,
379          long timestamp, Date commitTime, long revision, CommitRecordEntry entry)
380        throws Exception {
381        if (shell != null && entry.isFile()) {
382          String file = this.fileNamePrefix == null ? "" : this.fileNamePrefix;
383          if (entry.getToPath() == null) {
384            file += entry.getFromPath();
385          }
386          else {
387            file += entry.getToPath();
388          }
389          // if binary file, then totalLines, linesAdded, linesDeleted all set to
390          // zero.
391          int totalLines = entry.isTextFile() ? entry.getTotalLines() : 0;
392          int linesAdded = entry.isTextFile() ? entry.getLinesAdded() : 0;
393          int linesDeleted = entry.isTextFile() ? entry.getLinesDeleted() : 0;
394    
395          Map<String, String> pMap = new HashMap<String, String>();
396          String timestampString = Tstamp.makeTimestamp(timestamp).toString();
397          pMap.put("SensorDataType", "Commit");
398          pMap.put("Resource", file);
399          pMap.put("Tool", "Subversion");
400          pMap.put("Timestamp", timestampString);
401          pMap.put("Runtime", Tstamp.makeTimestamp(commitTime.getTime()).toString());
402          pMap.put("repository", this.repositoryName);
403          pMap.put("totalLines", String.valueOf(totalLines));
404          pMap.put("linesAdded", String.valueOf(linesAdded));
405          pMap.put("linesDeleted", String.valueOf(linesDeleted));
406          pMap.put("log", message);
407          shell.add(pMap);
408          if (this.isVerbose) {
409            System.out.printf("Sending SVN Commit: Timestamp: %s Resource: %s User: %s%n", 
410                timestampString, file, shell.getProperties().getSensorBaseUser());
411          }
412        }
413      }
414    }