001: package net.sourceforge.cruisecontrol.sourcecontrols;
002:
003: import java.io.BufferedReader;
004: import java.io.IOException;
005: import java.io.InputStream;
006: import java.io.InputStreamReader;
007: import java.io.Reader;
008: import java.text.DateFormat;
009: import java.text.ParseException;
010: import java.text.SimpleDateFormat;
011: import java.util.ArrayList;
012: import java.util.Date;
013: import java.util.List;
014: import java.util.Locale;
015: import java.util.Map;
016: import java.util.TimeZone;
017: import java.util.regex.Matcher;
018: import java.util.regex.Pattern;
019:
020: import net.sourceforge.cruisecontrol.CruiseControlException;
021: import net.sourceforge.cruisecontrol.Modification;
022: import net.sourceforge.cruisecontrol.SourceControl;
023: import net.sourceforge.cruisecontrol.util.Commandline;
024: import net.sourceforge.cruisecontrol.util.StreamLogger;
025: import net.sourceforge.cruisecontrol.util.ValidationHelper;
026:
027: import org.apache.log4j.Logger;
028:
029: /**
030: * The class implements the SourceControl interface to allow communication with
031: * Microsoft Visual Studio Team Foundation Server
032: *
033: * @author <a href="http://www.woodwardweb.com">Martin Woodward</a>
034: */
035: public class TeamFoundationServer implements SourceControl {
036:
037: private static final Logger LOG = Logger
038: .getLogger(TeamFoundationServer.class);
039:
040: /** UTC Date format - best one to pass dates across the wire. */
041: private static final String TFS_UTC_DATE_FORMAT = "yyyy-MM-dd'T'HH:mm:ss'Z'";
042:
043: /** Configuration parameters */
044:
045: private String server;
046: private String projectPath;
047: private String username;
048: private String password;
049: private String tfPath = "tf";
050: private String options;
051:
052: private final SourceControlProperties properties = new SourceControlProperties();
053:
054: /**
055: * The main getModification method called by the build loop. Responsible for
056: * querying history from TFS, parsing the results and then transforming that
057: * into a list of CruiseControl Modification objects.
058: *
059: * @see net.sourceforge.cruisecontrol.SourceControl
060: * getModifications(java.util.Date, java.util.Date)
061: */
062: public List getModifications(Date lastBuild, Date now) {
063:
064: List modifications = new ArrayList();
065: final Commandline command = buildHistoryCommand(lastBuild, now);
066:
067: try {
068: modifications = execHistoryCommand(command, lastBuild);
069: } catch (Exception e) {
070: LOG.error("Error executing tf history command " + command,
071: e);
072: }
073:
074: fillPropertiesIfNeeded(modifications);
075: return modifications;
076: }
077:
078: /**
079: * Populate the source control properties. As well as detecting if modifications found
080: * and if any deletion is found, also put the maximum changeset found in the modification list.
081: * The changeset ID represents the state of the repository at the time the modifications were
082: * detected and therefore can be used in a subsquent get, label etc to ensure consistency.
083: * @param modifications the list of modifications reported by TFS
084: */
085: void fillPropertiesIfNeeded(List modifications) {
086: if (!modifications.isEmpty()) {
087: properties.modificationFound();
088: int maxChangset = 0;
089: for (int i = 0; i < modifications.size(); i++) {
090: Modification modification = (Modification) modifications
091: .get(i);
092: maxChangset = Math.max(maxChangset, Integer
093: .parseInt(modification.revision));
094: Modification.ModifiedFile file = (Modification.ModifiedFile) modification.files
095: .get(0);
096: if (file.action.equals("delete")) {
097: properties.deletionFound();
098: break;
099: }
100: }
101: properties.put("tfschangeset", "" + maxChangset);
102: }
103: }
104:
105: /**
106: * Build a history command like the following:-
107: *
108: * tf history -noprompt -server:http://tfsserver:8080 $/TeamProjectName/path
109: * -version:D2006-12-01T01:01:01Z~D2006-12-13T20:00:00Z -recursive
110: * -format:detailed -login:DOMAIN\name,password
111: *
112: * For more details on history command syntax see
113: *
114: * <a href="http://msdn2.microsoft.com/en-us/library/yxtbh4yh(VS.80).aspx">
115: * http://msdn2.microsoft.com/en-us/library/yxtbh4yh(VS.80).aspx </a>
116: */
117: Commandline buildHistoryCommand(Date lastBuild, Date now) {
118:
119: Commandline command = new Commandline();
120: command.setExecutable(tfPath);
121: command.createArgument().setValue("history");
122: command.createArgument().setValue("-noprompt");
123: command.createArgument().setValue("-server:" + server);
124: command.createArgument().setValue(projectPath);
125:
126: command.createArgument().setValue(
127: "-version:D" + formatUTCDate(lastBuild) + "~D"
128: + formatUTCDate(now));
129:
130: command.createArgument().setValue("-recursive");
131: command.createArgument().setValue("-format:detailed");
132:
133: if (username != null && password != null) {
134: command.createArgument().setValue(
135: "-login:" + username + "," + password + "");
136: }
137:
138: if (options != null) {
139: command.createArgument().setValue(options);
140: }
141:
142: LOG.debug("Executing command: " + command);
143:
144: return command;
145: }
146:
147: private List execHistoryCommand(Commandline command, Date lastBuild)
148: throws InterruptedException, IOException, ParseException {
149:
150: Process p = command.execute();
151:
152: logErrorStream(p);
153: InputStream svnStream = p.getInputStream();
154: List modifications = parseStream(svnStream, lastBuild);
155:
156: p.waitFor();
157: p.getInputStream().close();
158: p.getOutputStream().close();
159: p.getErrorStream().close();
160:
161: return modifications;
162: }
163:
164: /**
165: * Helper method to send stderr from the tf command to CruiseControl stderr
166: */
167: private void logErrorStream(Process p) {
168: Thread stderr = new Thread(StreamLogger.getWarnPumper(LOG, p
169: .getErrorStream()));
170: stderr.start();
171: }
172:
173: /**
174: * Parse the result stream. Delegates to the TFSHistoryParser.parse method.
175: */
176: private List parseStream(InputStream tfStream, Date lastBuild)
177: throws IOException, ParseException {
178:
179: InputStreamReader reader = new InputStreamReader(tfStream,
180: "UTF-8");
181: return TFHistoryParser.parse(reader, lastBuild);
182: }
183:
184: /**
185: * Convert the passed date into the UTC Date format best used when talking
186: * to Team Foundation Server command line.
187: */
188: static String formatUTCDate(Date date) {
189: DateFormat f = new SimpleDateFormat(TFS_UTC_DATE_FORMAT);
190: f.setTimeZone(TimeZone.getTimeZone("GMT"));
191: return f.format(date);
192: }
193:
194: /**
195: * @see net.sourceforge.cruisecontrol.SourceControl#getProperties()
196: */
197: public Map getProperties() {
198: return properties.getPropertiesAndReset();
199: }
200:
201: /**
202: * Validates that the plug-in has its mandatory inputs satisfied. The only
203: * mandatory requirements are a server and project path.
204: *
205: * @see net.sourceforge.cruisecontrol.SourceControl#validate()
206: */
207: public void validate() throws CruiseControlException {
208: ValidationHelper.assertIsSet(server, "server", this .getClass());
209: ValidationHelper.assertIsSet(projectPath, "projectPath", this
210: .getClass());
211: ValidationHelper.assertTrue(projectPath.startsWith("$/"),
212: "A TFS server path must begin with $/");
213: }
214:
215: /**
216: * Internal class to handle parsing of TF command line output.
217: */
218: static final class TFHistoryParser {
219:
220: private TFHistoryParser() {
221: }
222:
223: private static final String CHANGESET_SEPERATOR = "---------------------------------";
224:
225: /**
226: * The magic regex to identify the key data elements within the
227: * changeset *
228: */
229: private static final Pattern PATTERN_CHANGESET = Pattern
230: .compile("^[^:]*:[ \t]([0-9]*)\n"
231: + "[^:]*:[ \t](.*)\n[^:]*:[ \t](.*)\n"
232: + "[^:]*:((?:\n.*)*)\n\n[^\n :]*:(?=\n )((?:\n[ \t]+.*)*)");
233:
234: /**
235: * An additional regex to split the items into their parts (change type
236: * and filename) *
237: */
238: private static final Pattern PATTERN_ITEM = Pattern
239: .compile("\n ([^$]+) (\\$/.*)");
240:
241: /**
242: * Parse the passed stream of data from the command line.
243: */
244: static List parse(Reader reader, Date lastBuild)
245: throws IOException, ParseException {
246: ArrayList modifications = new ArrayList();
247: StringBuffer buffer = new StringBuffer();
248:
249: BufferedReader br = new BufferedReader(reader);
250: String line;
251: int linecount = 0;
252:
253: while ((line = br.readLine()) != null) {
254: linecount++;
255: if (line.startsWith(CHANGESET_SEPERATOR)) {
256: if (linecount > 1) {
257: // We are starting a new changeset.
258: modifications.addAll(parseChangeset(buffer
259: .toString(), lastBuild));
260: buffer.setLength(0);
261: }
262: } else {
263: buffer.append(line).append('\n');
264: }
265: }
266:
267: // Add the last changeset
268: modifications.addAll(parseChangeset(buffer.toString(),
269: lastBuild));
270:
271: return modifications;
272: }
273:
274: /**
275: * Parse the changeset data and convert into a list of CruiseControl
276: * modifications.
277: */
278: static ArrayList parseChangeset(String data, Date lastBuild)
279: throws ParseException {
280: if (LOG.isDebugEnabled()) {
281: LOG.debug("Parsing Changeset Data:\n" + data);
282: }
283:
284: ArrayList modifications = new ArrayList();
285:
286: Matcher m = PATTERN_CHANGESET.matcher(data);
287: if (m.find()) {
288: String revision = m.group(1);
289: String userName = m.group(2);
290:
291: Date modifiedTime = parseDate(m.group(3));
292:
293: // CC-735. Ignore changesets that occured before the specified lastBuild.
294: if (modifiedTime.compareTo(lastBuild) < 0) {
295: return new ArrayList();
296: }
297:
298: // Remove the indentation from the comment
299: String comment = m.group(4).replaceAll("\n ", "\n");
300: if (comment.length() > 0) {
301: // remove leading "\n"
302: comment = comment.trim();
303: }
304:
305: // Parse the items.
306: Matcher itemMatcher = PATTERN_ITEM.matcher(m.group(5));
307: int items = 0;
308: while (itemMatcher.find()) {
309: items++;
310: // Create the modification. Note that although the
311: // Modification class model supports more than one Modified
312: // file per modification most of the things downstream (such
313: // as the report JSP, email noticiation etc) do not take
314: // this into account. Therefore we flatten 1 changeset
315: // containing three files into three modifications
316: // with the same revision.
317:
318: Modification modification = new Modification("tfs");
319: modification.revision = revision;
320: modification.userName = userName;
321: modification.modifiedTime = modifiedTime;
322: modification.comment = comment;
323:
324: // In a similar way to Subversion, TFS will record additions
325: // of folders etc
326: // Therefore we have to report all modifictaion by the file
327: // and not split
328: // into file and folder as there is no easy way to
329: // distinguish
330: // $/path/filename
331: // from
332: // $/path/foldername
333: //
334: Modification.ModifiedFile modfile = modification
335: .createModifiedFile(itemMatcher.group(2),
336: null);
337: if (!modfile.fileName.startsWith("$/")) {
338: // If this happens then we have a bug, output some data
339: // to make it easy to figure out what the problem was so
340: // that we can fix it.
341: throw new ParseException(
342: "Parse error. Mistakenly identified \""
343: + modfile.fileName
344: + "\" as an item, but it does not appear to "
345: + "be a valid TFS path. Please report this as a bug. Changeset"
346: + "data = \"\n" + data
347: + "\n\".", itemMatcher.start());
348: }
349: modfile.action = itemMatcher.group(1).trim();
350: modfile.revision = modification.revision;
351:
352: modifications.add(modification);
353: }
354: if (items < 1) {
355: // We should always find at least one item. If we don't
356: // then this will be because we have not parsed correctly.
357: throw new ParseException(
358: "Parse error. Unable to find an item within "
359: + "a changeset. Please report this as a bug. Changeset"
360: + "data = \"\n" + data + "\n\".", 0);
361: }
362: }
363:
364: return modifications;
365: }
366:
367: protected static Date parseDate(String dateString)
368: throws ParseException {
369: Date date = null;
370: try {
371: // Use the deprecated Date.parse method as this is very good at detecting
372: // dates commonly output by the US and UK standard locales of dotnet that
373: // are output by the Microsoft command line client.
374: date = new Date(Date.parse(dateString));
375: } catch (IllegalArgumentException e) {
376: // ignore - parse failed.
377: }
378: if (date == null) {
379: // The old fashioned way did not work. Let's try it using a more
380: // complex alternative.
381: DateFormat[] formats = createDateFormatsForLocaleAndTimeZone(
382: null, null);
383: return parseWithFormats(dateString, formats);
384: }
385: return date;
386: }
387:
388: private static Date parseWithFormats(String input,
389: DateFormat[] formats) throws ParseException {
390: ParseException parseException = null;
391: for (int i = 0; i < formats.length; i++) {
392: try {
393: return formats[i].parse(input);
394: } catch (ParseException ex) {
395: parseException = ex;
396: }
397: }
398:
399: throw parseException;
400: }
401:
402: /**
403: * Build an array of DateFormats that are commonly used for this locale
404: * and timezone.
405: */
406: private static DateFormat[] createDateFormatsForLocaleAndTimeZone(
407: Locale locale, TimeZone timeZone) {
408: if (locale == null) {
409: locale = Locale.getDefault();
410: }
411:
412: if (timeZone == null) {
413: timeZone = TimeZone.getDefault();
414: }
415:
416: List formats = new ArrayList();
417:
418: for (int dateStyle = DateFormat.FULL; dateStyle <= DateFormat.SHORT; dateStyle++) {
419: for (int timeStyle = DateFormat.FULL; timeStyle <= DateFormat.SHORT; timeStyle++) {
420: DateFormat df = DateFormat.getDateTimeInstance(
421: dateStyle, timeStyle, locale);
422: if (timeZone != null) {
423: df.setTimeZone(timeZone);
424: }
425: formats.add(df);
426: }
427: }
428:
429: for (int dateStyle = DateFormat.FULL; dateStyle <= DateFormat.SHORT; dateStyle++) {
430: DateFormat df = DateFormat.getDateInstance(dateStyle,
431: locale);
432: df.setTimeZone(timeZone);
433: formats.add(df);
434: }
435:
436: return (DateFormat[]) formats
437: .toArray(new DateFormat[formats.size()]);
438: }
439:
440: }
441:
442: // --- Property setters
443:
444: /**
445: * If the username or password is not supplied, then none will be passed to
446: * the command. On windows system using the Microsoft tf.exe command line
447: * client, the credential of that the CruiseControl process is running as
448: * will be used for the connection to the server.
449: *
450: * @param password
451: * the password to set
452: */
453: public void setPassword(String password) {
454: this .password = password;
455: }
456:
457: /**
458: * Mandatory. The path from which you want to check for modifications.
459: * Usually something like "$/TeamProjectName/path/to/project"
460: *
461: * Any changes in and folder in that path or below will register as
462: * modifications.
463: *
464: * @param projectPath
465: * the projectPath to set
466: */
467: public void setProjectPath(String projectPath) {
468: this .projectPath = projectPath;
469: }
470:
471: /**
472: * The server to talk to. The easiest way to define this is in the URL
473: * format http://servername:8080 where the URL is that to the TFS
474: * Application Tier. On windows systems running in an environment where the
475: * server has already been registered (using the Microsoft graphical client
476: * for example) and the tf command being used is the Microsoft one, then the
477: * servername only could be used as it will resolve this in the registry -
478: * however the URL syntax is preferred as it is more accurate and easier to
479: * change.
480: *
481: * @param server
482: * the server to set
483: */
484: public void setServer(String server) {
485: this .server = server;
486: }
487:
488: /**
489: * The username to use when talking to TFS. This should be in the format
490: * DOMAIN\name or name@DOMAIN if the domain portion is required. Note that
491: * name@DOMAIN is the easiest format to use from Unix based systems. If the
492: * username contains characters likely to cause problems when passed to the
493: * command line then they can be escaped in quotes by passing the following
494: * into the config.xml:- <code>&quot;name&quot;</code>
495: *
496: * If the username or password is not supplied, then none will be passed to
497: * the command. On windows system using the Microsoft tf.exe command line
498: * client, the credential of that the CruiseControl process is running as
499: * will be used for the connection to the server.
500: *
501: * @param username
502: * the username to set
503: */
504: public void setUsername(String username) {
505: this .username = username;
506: }
507:
508: /**
509: * The path to the tf command. Either the "tf.exe" command
510: * provided by Microsoft in the <a
511: * href="http://download.microsoft.com/download/2/a/d/2ad44873-8ccb-4a1b-9c0d-23224b3ba34c/VSTFClient.img">
512: * Team Explorer Client</a> can be used or the "tf" command line
513: * client provided by <a href="http://www.teamprise.com">Teamprise</a> can
514: * be used. The Teamprise client works cross-platform. Both clients are free
515: * to use provided the developers using CruiseControl have a TFS Client
516: * Access License (and in the case of Teamprise a license to the Teamprise
517: * command line client).
518: *
519: * If not supplied then the command "tf" will be called and CruiseControl
520: * will rely on that command being able to be found in the path.
521: *
522: * @param tfPath
523: * the path where the tf command resides
524: */
525: public void setTfPath(String tfPath) {
526: this .tfPath = tfPath;
527: }
528:
529: /**
530: * An optional argument to add to the end of the history command that is
531: * generated
532: *
533: * @param options
534: * the options to set
535: */
536: public void setOptions(String options) {
537: this.options = options;
538: }
539: }
|