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 }