001: /********************************************************************************
002: * CruiseControl, a Continuous Integration Toolkit
003: * Copyright (c) 2001, ThoughtWorks, Inc.
004: * 200 E. Randolph, 25th Floor
005: * Chicago, IL 60601 USA
006: * All rights reserved.
007: *
008: * Redistribution and use in source and binary forms, with or without
009: * modification, are permitted provided that the following conditions
010: * are met:
011: *
012: * + Redistributions of source code must retain the above copyright
013: * notice, this list of conditions and the following disclaimer.
014: *
015: * + Redistributions in binary form must reproduce the above
016: * copyright notice, this list of conditions and the following
017: * disclaimer in the documentation and/or other materials provided
018: * with the distribution.
019: *
020: * + Neither the name of ThoughtWorks, Inc., CruiseControl, nor the
021: * names of its contributors may be used to endorse or promote
022: * products derived from this software without specific prior
023: * written permission.
024: *
025: * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
026: * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
027: * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
028: * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR
029: * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
030: * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
031: * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
032: * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
033: * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
034: * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
035: * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
036: ********************************************************************************/package net.sourceforge.cruisecontrol.sourcecontrols;
037:
038: import net.sourceforge.cruisecontrol.CruiseControlException;
039: import net.sourceforge.cruisecontrol.Modification;
040: import net.sourceforge.cruisecontrol.SourceControl;
041: import net.sourceforge.cruisecontrol.util.Commandline;
042: import net.sourceforge.cruisecontrol.util.IO;
043: import net.sourceforge.cruisecontrol.util.StreamLogger;
044: import net.sourceforge.cruisecontrol.util.Util;
045: import net.sourceforge.cruisecontrol.util.ValidationHelper;
046: import org.apache.log4j.Logger;
047: import org.jdom.Document;
048: import org.jdom.Element;
049: import org.jdom.JDOMException;
050: import org.jdom.input.SAXBuilder;
051:
052: import java.io.BufferedReader;
053: import java.io.File;
054: import java.io.IOException;
055: import java.io.InputStream;
056: import java.io.InputStreamReader;
057: import java.io.Reader;
058: import java.text.DateFormat;
059: import java.text.ParseException;
060: import java.text.SimpleDateFormat;
061: import java.util.ArrayList;
062: import java.util.Arrays;
063: import java.util.Date;
064: import java.util.HashMap;
065: import java.util.Iterator;
066: import java.util.List;
067: import java.util.Map;
068: import java.util.TimeZone;
069:
070: /**
071: * This class implements the SourceControl methods for a Subversion repository.
072: * The call to Subversion is assumed to work without any setup. This implies
073: * that either authentication data must be available or the login parameters are
074: * specified in the cc configuration file.
075: *
076: * Note: You can also observe for changes a Subversion repository that you have
077: * not checked out locally.
078: *
079: * @see <a href="http://subversion.tigris.org/">subversion.tigris.org</a>
080: * @author <a href="etienne.studer@canoo.com">Etienne Studer</a>
081: */
082: public class SVN implements SourceControl {
083:
084: private static final Logger LOG = Logger.getLogger(SVN.class);
085:
086: /** Date format expected by Subversion */
087: private static final String SVN_DATE_FORMAT_IN = "yyyy-MM-dd'T'HH:mm:ss'Z'";
088:
089: /** Date format returned by Subversion in XML output */
090: private static final String SVN_DATE_FORMAT_OUT = "yyyy-MM-dd'T'HH:mm:ss.SSS";
091:
092: private final SourceControlProperties properties = new SourceControlProperties();
093:
094: /** Configuration parameters */
095: private String repositoryLocation;
096: private String localWorkingCopy;
097: private String userName;
098: private String password;
099: private boolean checkExternals = false;
100:
101: private boolean useLocalRevision = false;
102:
103: public Map getProperties() {
104: return properties.getPropertiesAndReset();
105: }
106:
107: public void setProperty(String property) {
108: properties.assignPropertyName(property);
109: }
110:
111: public void setPropertyOnDelete(String propertyOnDelete) {
112: properties.assignPropertyOnDeleteName(propertyOnDelete);
113: }
114:
115: /**
116: * Sets whether externals used by the project should also be checked
117: * for modifications.
118: *
119: * @param value true/false
120: */
121: public void setCheckExternals(boolean value) {
122: checkExternals = value;
123: }
124:
125: /**
126: * Sets the repository location to use when making calls to Subversion.
127: *
128: * @param repositoryLocation String indicating the url to the Subversion
129: * repository on which to find the log history.
130: */
131: public void setRepositoryLocation(String repositoryLocation) {
132: this .repositoryLocation = repositoryLocation;
133: }
134:
135: /**
136: * Sets the local working copy to use when making calls to Subversion.
137: *
138: * @param localWorkingCopy String indicating the relative or absolute path
139: * to the local working copy of the Subversion
140: * repository of which to find the log history.
141: */
142: public void setLocalWorkingCopy(String localWorkingCopy) {
143: this .localWorkingCopy = localWorkingCopy;
144: }
145:
146: /**
147: * Sets the username for authentication.
148: * @param userName svn user
149: */
150: public void setUsername(String userName) {
151: this .userName = userName;
152: }
153:
154: /**
155: * Sets the password for authentication.
156: * @param password svn password
157: */
158: public void setPassword(String password) {
159: this .password = password;
160: }
161:
162: /**
163: * This method validates that at least the repository location or the local
164: * working copy location has been specified.
165: *
166: * @throws CruiseControlException Thrown when the repository location and
167: * the local working copy location are both
168: * null
169: */
170: public void validate() throws CruiseControlException {
171: ValidationHelper
172: .assertTrue(
173: repositoryLocation != null
174: || localWorkingCopy != null,
175: "At least 'repositoryLocation'or 'localWorkingCopy' is a required attribute on the Subversion task ");
176:
177: if (localWorkingCopy != null) {
178: File workingDir = new File(localWorkingCopy);
179: ValidationHelper.assertTrue(workingDir.exists()
180: && workingDir.isDirectory(),
181: "'localWorkingCopy' must be an existing directory. Was "
182: + workingDir.getAbsolutePath());
183: }
184: }
185:
186: /**
187: * Returns a list of modifications detailing all the changes between
188: * the last build and the latest revision in the repository.
189: * @return the list of modifications, or an empty list if we failed
190: * to retrieve the changes.
191: */
192: public List getModifications(Date lastBuild, Date now) {
193: HashMap directories = new HashMap();
194: Commandline propCommand = new Commandline();
195: // the propget command can be pretty expensive on large projects
196: // so only execute if the checkExternals flag is set in the config
197: if (checkExternals) {
198: try {
199: propCommand = buildPropgetCommand();
200: } catch (CruiseControlException e) {
201: LOG.error("Error building history command", e);
202: }
203: try {
204: directories = execPropgetCommand(propCommand);
205: } catch (Exception e) {
206: LOG.error("Error executing svn propget command "
207: + propCommand, e);
208: }
209: }
210:
211: List modifications = new ArrayList();
212: Commandline command;
213: String path;
214: String svnURL;
215: HashMap commandsAndPaths = new HashMap();
216: try {
217: // always check the root
218: String startRevision = formatSVNDate(lastBuild);
219: String endRevision;
220: if (useLocalRevision) {
221: endRevision = execInfoCommand(buildInfoCommand(null));
222: } else {
223: endRevision = formatSVNDate(now);
224: }
225: command = buildHistoryCommand(startRevision, endRevision);
226: commandsAndPaths.put(command, null);
227: for (Iterator iter = directories.keySet().iterator(); iter
228: .hasNext();) {
229: String directory = (String) iter.next();
230: if (useLocalRevision) {
231: endRevision = execInfoCommand(buildInfoCommand(directory));
232: } else {
233: endRevision = formatSVNDate(now);
234: }
235: ArrayList externals = (ArrayList) directories
236: .get(directory);
237: for (Iterator eiter = externals.iterator(); eiter
238: .hasNext();) {
239: String[] external = (String[]) eiter.next();
240: path = directory + "/" + external[0];
241: svnURL = external[1];
242: if (repositoryLocation != null) {
243: command = buildHistoryCommand(startRevision,
244: endRevision, svnURL);
245: commandsAndPaths.put(command, null);
246: } else {
247: command = buildHistoryCommand(startRevision,
248: endRevision, svnURL);
249: commandsAndPaths.put(command, path);
250: }
251: }
252: }
253: } catch (CruiseControlException e) {
254: LOG.error("Error building history command", e);
255: return modifications;
256: }
257: try {
258: for (Iterator iter = commandsAndPaths.keySet().iterator(); iter
259: .hasNext();) {
260: command = (Commandline) iter.next();
261: path = (String) commandsAndPaths.get(command);
262: modifications.addAll(execHistoryCommand(command,
263: lastBuild, path));
264: }
265: } catch (Exception e) {
266: LOG.error("Error executing svn log command " + command, e);
267: }
268: fillPropertiesIfNeeded(modifications);
269: return modifications;
270: }
271:
272: /**
273: * Generates the command line for the svn propget command.
274: *
275: * For example:
276: *
277: * 'svn propget -R svn:externals repositoryLocation'
278: * @return new command line object
279: * @throws net.sourceforge.cruisecontrol.CruiseControlException if working directory is invalid
280: */
281: Commandline buildPropgetCommand() throws CruiseControlException {
282: Commandline command = new Commandline();
283: command.setExecutable("svn");
284:
285: if (localWorkingCopy != null) {
286: command.setWorkingDirectory(localWorkingCopy);
287: }
288:
289: command.createArgument("propget");
290: command.createArgument("-R");
291: command.createArgument("--non-interactive");
292: command.createArgument("svn:externals");
293:
294: if (repositoryLocation != null) {
295: command.createArgument(repositoryLocation);
296: }
297:
298: LOG.debug("Executing command: " + command);
299:
300: return command;
301: }
302:
303: Commandline buildInfoCommand(String path)
304: throws CruiseControlException {
305: Commandline command = new Commandline();
306: command.setExecutable("svn");
307:
308: if (localWorkingCopy != null) {
309: command.setWorkingDirectory(localWorkingCopy);
310: }
311: command.createArgument("info");
312: command.createArgument("--xml");
313: if (path != null) {
314: command.createArgument(path);
315: }
316: LOG.debug("Executing command: " + command);
317:
318: return command;
319: }
320:
321: /**
322: * Generates the command line for the svn log command.
323: *
324: * For example:
325: *
326: * 'svn log --non-interactive --xml -v -r "{lastbuildTime}":"{checkTime}" repositoryLocation'
327: * @return history command
328: * @param lastBuild date
329: * @param checkTime checkTime
330: * @throws net.sourceforge.cruisecontrol.CruiseControlException exception
331: */
332: Commandline buildHistoryCommand(String lastBuild, String checkTime)
333: throws CruiseControlException {
334: return buildHistoryCommand(lastBuild, checkTime, null);
335: }
336:
337: Commandline buildHistoryCommand(String lastBuild, String checkTime,
338: String path) throws CruiseControlException {
339:
340: Commandline command = new Commandline();
341: command.setExecutable("svn");
342:
343: if (localWorkingCopy != null) {
344: command.setWorkingDirectory(localWorkingCopy);
345: }
346:
347: command.createArgument("log");
348: command.createArgument("--non-interactive");
349: command.createArgument("--xml");
350: command.createArgument("-v");
351: command.createArgument("-r");
352: command.createArgument(lastBuild + ":" + checkTime);
353:
354: if (userName != null) {
355: command.createArguments("--username", userName);
356: }
357: if (password != null) {
358: command.createArguments("--password", password);
359: }
360: if (path != null) {
361: command.createArgument(path);
362: } else if (repositoryLocation != null) {
363: command.createArgument(repositoryLocation);
364: }
365:
366: LOG.debug("Executing command: " + command);
367:
368: return command;
369: }
370:
371: static String formatSVNDate(Date date) {
372: return formatSVNDate(date, Util.isWindows());
373: }
374:
375: static String formatSVNDate(Date lastBuild, boolean isWindows) {
376: DateFormat f = new SimpleDateFormat(SVN_DATE_FORMAT_IN);
377: f.setTimeZone(TimeZone.getTimeZone("GMT"));
378: String dateStr = f.format(lastBuild);
379: if (isWindows) {
380: return "\"{" + dateStr + "}\"";
381: } else {
382: return "{" + dateStr + "}";
383: }
384: }
385:
386: private static HashMap execPropgetCommand(Commandline command)
387: throws InterruptedException, IOException {
388:
389: final Process p = command.execute();
390:
391: final Thread stderr = logErrorStream(p);
392: final BufferedReader reader = new BufferedReader(
393: new InputStreamReader(p.getInputStream(), "UTF8"));
394:
395: final HashMap directories = new HashMap();
396: try {
397: String line;
398: String currentDir = null;
399:
400: while ((line = reader.readLine()) != null) {
401: String[] split = line.split(" - ");
402: // the directory containing the externals
403: if (split.length > 1) {
404: currentDir = split[0];
405: directories.put(currentDir, new ArrayList());
406: line = split[1];
407: }
408: split = line.split(" ");
409: if (!split[0].equals("")) {
410: ArrayList externals = (ArrayList) directories
411: .get(currentDir);
412: // split contains: [externalPath, externalSvnURL]
413: externals.add(split);
414: }
415: }
416:
417: p.waitFor();
418: stderr.join();
419: } finally {
420: reader.close();
421: IO.close(p);
422: }
423:
424: return directories;
425: }
426:
427: private static List execHistoryCommand(Commandline command,
428: Date lastBuild, String externalPath)
429: throws InterruptedException, IOException, ParseException,
430: JDOMException {
431:
432: final Process p = command.execute();
433:
434: final Thread stderr = logErrorStream(p);
435: final InputStreamReader reader = new InputStreamReader(p
436: .getInputStream(), "UTF-8");
437:
438: final List modifications;
439: try {
440: modifications = SVNLogXMLParser.parseAndFilter(reader,
441: lastBuild, externalPath);
442:
443: p.waitFor();
444: stderr.join();
445: } finally {
446: reader.close();
447: IO.close(p);
448: }
449:
450: return modifications;
451: }
452:
453: private String execInfoCommand(final Commandline command)
454: throws CruiseControlException {
455: try {
456: final Process p = command.execute();
457:
458: final Thread stderr = logErrorStream(p);
459: final InputStream svnStream = p.getInputStream();
460: final InputStreamReader reader = new InputStreamReader(
461: svnStream, "UTF-8");
462: final String revision;
463: try {
464: revision = SVNInfoXMLParser.parse(reader);
465:
466: p.waitFor();
467: stderr.join();
468: } finally {
469: reader.close();
470: IO.close(p);
471: }
472:
473: return revision;
474: } catch (IOException e) {
475: throw new CruiseControlException(e);
476: } catch (JDOMException e) {
477: throw new CruiseControlException(e);
478: } catch (InterruptedException e) {
479: throw new CruiseControlException(e);
480: }
481: }
482:
483: private static Thread logErrorStream(Process p) {
484: final Thread stderr = new Thread(StreamLogger.getWarnPumper(
485: LOG, p.getErrorStream()));
486: stderr.start();
487: return stderr;
488: }
489:
490: void fillPropertiesIfNeeded(List modifications) {
491: if (!modifications.isEmpty()) {
492: properties.modificationFound();
493: int maxRevision = 0;
494: for (int i = 0; i < modifications.size(); i++) {
495: Modification modification = (Modification) modifications
496: .get(i);
497: maxRevision = Math.max(maxRevision, Integer
498: .parseInt(modification.revision));
499: Modification.ModifiedFile file = (Modification.ModifiedFile) modification.files
500: .get(0);
501: if (file.action.equals("deleted")) {
502: properties.deletionFound();
503: }
504: }
505: properties.put("svnrevision", "" + maxRevision);
506: }
507: }
508:
509: public static DateFormat getOutDateFormatter() {
510: DateFormat f = new SimpleDateFormat(SVN_DATE_FORMAT_OUT);
511: f.setTimeZone(TimeZone.getTimeZone("GMT"));
512: return f;
513: }
514:
515: static final class SVNLogXMLParser {
516:
517: private SVNLogXMLParser() {
518: }
519:
520: static List parseAndFilter(Reader reader, Date lastBuild)
521: throws ParseException, JDOMException, IOException {
522: return parseAndFilter(reader, lastBuild, null);
523: }
524:
525: static List parseAndFilter(Reader reader, Date lastBuild,
526: String externalPath) throws ParseException,
527: JDOMException, IOException {
528: Modification[] modifications = parse(reader, externalPath);
529: return filterModifications(modifications, lastBuild);
530: }
531:
532: static Modification[] parse(Reader reader)
533: throws ParseException, JDOMException, IOException {
534: return parse(reader, null);
535: }
536:
537: static Modification[] parse(Reader reader, String externalPath)
538: throws ParseException, JDOMException, IOException {
539:
540: SAXBuilder builder = new SAXBuilder(false);
541: Document document = builder.build(reader);
542: return parseDOMTree(document, externalPath);
543: }
544:
545: static Modification[] parseDOMTree(Document document,
546: String externalPath) throws ParseException {
547: List modifications = new ArrayList();
548:
549: Element rootElement = document.getRootElement();
550: List logEntries = rootElement.getChildren("logentry");
551: for (Iterator iterator = logEntries.iterator(); iterator
552: .hasNext();) {
553: Element logEntry = (Element) iterator.next();
554:
555: Modification[] modificationsOfRevision = parseLogEntry(
556: logEntry, externalPath);
557: modifications.addAll(Arrays
558: .asList(modificationsOfRevision));
559: }
560:
561: return (Modification[]) modifications
562: .toArray(new Modification[modifications.size()]);
563: }
564:
565: static Modification[] parseLogEntry(Element logEntry,
566: String externalPath) throws ParseException {
567: List modifications = new ArrayList();
568:
569: Element logEntryPaths = logEntry.getChild("paths");
570: if (logEntryPaths != null) {
571: List paths = logEntryPaths.getChildren("path");
572: for (Iterator iterator = paths.iterator(); iterator
573: .hasNext();) {
574: Element path = (Element) iterator.next();
575:
576: Modification modification = new Modification("svn");
577:
578: modification.modifiedTime = convertDate(logEntry
579: .getChildText("date"));
580: modification.userName = logEntry
581: .getChildText("author");
582: modification.comment = logEntry.getChildText("msg");
583: modification.revision = logEntry
584: .getAttributeValue("revision");
585:
586: Modification.ModifiedFile modfile = modification
587: .createModifiedFile(path.getText(), null);
588: // modfile.folderName seems to add too many /'s
589: if (externalPath != null) {
590: modfile.fileName = "/" + externalPath + ":"
591: + modfile.fileName;
592: }
593: modfile.action = convertAction(path
594: .getAttributeValue("action"));
595: modfile.revision = modification.revision;
596:
597: modifications.add(modification);
598: }
599: }
600:
601: return (Modification[]) modifications
602: .toArray(new Modification[modifications.size()]);
603: }
604:
605: /**
606: * Converts the specified SVN date string into a Date.
607: * @param date with format "yyyy-MM-dd'T'HH:mm:ss.SSS" + "...Z"
608: * @return converted date
609: * @throws ParseException if specified date doesn't match the expected format
610: */
611: static Date convertDate(String date) throws ParseException {
612: final int zIndex = date.indexOf('Z');
613: if (zIndex - 3 < 0) {
614: throw new ParseException(
615: date
616: + " doesn't match the expected subversion date format",
617: date.length());
618: }
619: String withoutMicroSeconds = date.substring(0, zIndex - 3);
620:
621: return getOutDateFormatter().parse(withoutMicroSeconds);
622: }
623:
624: static String convertAction(String action) {
625: if (action.equals("A")) {
626: return "added";
627: }
628: if (action.equals("M")) {
629: return "modified";
630: }
631: if (action.equals("D")) {
632: return "deleted";
633: }
634: return "unknown";
635: }
636:
637: /**
638: * Unlike CVS, Subversion maps dates to revisions which leads to a
639: * different behavior when using the svn log command in conjunction with
640: * dates, e.g., a date maps to a revision but the revision may have been
641: * created earlier than the specified date. Therefore, if we are only
642: * interested in changes that took place after the last build date, we
643: * have to filter the modifications returned from the log command and
644: * omit modifications that are older than the last build date.
645: *
646: * @see <a href="http://subversion.tigris.org/">subversion.tigris.org</a>
647: * @return subset of modifications
648: * @param modifications source
649: * @param lastBuild last build date
650: */
651: static List filterModifications(Modification[] modifications,
652: Date lastBuild) {
653: List filtered = new ArrayList();
654: for (int i = 0; i < modifications.length; i++) {
655: Modification modification = modifications[i];
656: if (modification.modifiedTime.getTime() > lastBuild
657: .getTime()) {
658: filtered.add(modification);
659: }
660: }
661: return filtered;
662: }
663: }
664:
665: static final class SVNInfoXMLParser {
666: private SVNInfoXMLParser() {
667: }
668:
669: public static String parse(final Reader reader)
670: throws JDOMException, IOException {
671: final SAXBuilder builder = new SAXBuilder(false);
672: final Document document = builder.build(reader);
673: return document.getRootElement().getChild("entry")
674: .getAttribute("revision").getValue();
675: }
676:
677: }
678:
679: public void setUseLocalRevision(boolean useLocalRevision) {
680: this.useLocalRevision = useLocalRevision;
681: }
682: }
|