com.microsoft.alm.plugin.external.commands.Command.java Source code

Java tutorial

Introduction

Here is the source code for com.microsoft.alm.plugin.external.commands.Command.java

Source

// Copyright (c) Microsoft. All rights reserved.
// Licensed under the MIT license. See License.txt in the project root.

package com.microsoft.alm.plugin.external.commands;

import com.microsoft.alm.common.utils.ArgumentHelper;
import com.microsoft.alm.helpers.Path;
import com.microsoft.alm.plugin.context.ServerContext;
import com.microsoft.alm.plugin.external.ToolRunner;
import com.microsoft.alm.plugin.external.ToolRunnerCache;
import com.microsoft.alm.plugin.external.exceptions.ToolException;
import com.microsoft.alm.plugin.external.exceptions.ToolParseFailureException;
import com.microsoft.alm.plugin.external.tools.TfTool;
import jersey.repackaged.com.google.common.util.concurrent.SettableFuture;
import org.apache.commons.lang.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.w3c.dom.NamedNodeMap;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xml.sax.InputSource;
import sun.security.util.Debug;

import javax.xml.xpath.XPath;
import javax.xml.xpath.XPathConstants;
import javax.xml.xpath.XPathExpressionException;
import javax.xml.xpath.XPathFactory;
import java.io.StringReader;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * Base class for all TF commands. This class provides an argument builder for the common arguments for all commmands:
 * name of the command, no prompt, collection url, and login info.
 * If no context is provided the collection and login parameters are simply left off (this should be fine if the command
 * is a local command).
 * All commands must also provide an implementation of the parseOutput method. This method takes the output readers for
 * command and converts them into an instance of T
 *
 * @param <T>
 */
public abstract class Command<T> {
    private static final Logger logger = LoggerFactory.getLogger(Command.class);

    private static final Pattern CHANGESET_NUMBER_PATTERN = Pattern.compile("#(\\d+)");

    public static final int OUTPUT_TYPE_INFO = 0;
    public static final int OUTPUT_TYPE_WARNING = 1;
    public static final int OUTPUT_TYPE_ERROR = 2;

    private static final String WARNING_PREFIX = "WARN ";
    private static final String XML_PREFIX = "<?xml ";

    private final String name;

    // The server context provides the server url and credentials for commands
    // Note that this may be null in some cases
    private final ServerContext context;

    public interface Listener<T> {
        /**
         * This method is called to notify the owner of progress made by the command process.
         *
         * @param output
         * @param outputType
         * @param percentComplete
         */
        void progress(final String output, final int outputType, final int percentComplete);

        /**
         * This method is called to notify the owner that the command process has completed.
         * Check the error parameter first to see if anything went wrong. If the error is
         * set, the result parameter may be null.
         *
         * @param result
         * @param error
         */
        void completed(final T result, final Throwable error);
    }

    public Command(final String name, final ServerContext context) {
        this.name = name;
        this.context = context;
    }

    public ToolRunner.ArgumentBuilder getArgumentBuilder() {
        final ToolRunner.ArgumentBuilder builder = new ToolRunner.ArgumentBuilder().add(name).addSwitch("noprompt");
        if (context != null && context.getCollectionURI() != null) {
            builder.addSwitch("collection", context.getCollectionURI().toString());
            if (context.getAuthenticationInfo() != null) {
                builder.addSwitch("login", context.getAuthenticationInfo().getUserName() + ","
                        + context.getAuthenticationInfo().getPassword(), true);
            }
        }

        return builder;
    }

    /**
     * This method starts the command after hooking up the listener. It returns immediately and calls the listener
     * when the command process finishes.
     *
     * @param listener
     */
    public void run(final Listener<T> listener) {
        final StringBuilder stdout = new StringBuilder();
        final StringBuilder stderr = new StringBuilder();
        ArgumentHelper.checkNotNull(listener, "listener");
        final ToolRunner runner = ToolRunnerCache.getRunningToolRunner(TfTool.getValidLocation(),
                getArgumentBuilder(), new ToolRunner.Listener() {
                    @Override
                    public void processStandardOutput(final String line) {
                        logger.info("CMD: " + line);
                        stdout.append(line + "\n");
                        listener.progress(line, OUTPUT_TYPE_INFO, 50);
                    }

                    @Override
                    public void processStandardError(final String line) {
                        logger.info("ERROR: " + line);
                        stderr.append(line + "\n");
                        listener.progress(line, OUTPUT_TYPE_ERROR, 50);
                    }

                    @Override
                    public void processException(final Throwable throwable) {
                        logger.info("ERROR: " + throwable.toString());
                        listener.progress("", OUTPUT_TYPE_INFO, 100);
                        listener.completed(null, throwable);
                    }

                    @Override
                    public void completed(final int returnCode) {
                        listener.progress("Parsing command output", OUTPUT_TYPE_INFO, 99);

                        Throwable error = null;
                        T result = null;
                        try {
                            //TODO there are some commands that write errors to stdout and simply return a non-zero exit code (i.e. when a workspace is not found by name)
                            //TODO we may want to pass in the return code to the parse method or something like that to allow the command to inspect this info as well.
                            result = parseOutput(stdout.toString(), stderr.toString());
                            TfTool.throwBadExitCode(interpretReturnCode(returnCode));
                        } catch (Throwable throwable) {
                            logger.warn("CMD: parsing output failed", throwable);
                            error = throwable;
                        }
                        listener.progress("", OUTPUT_TYPE_INFO, 100);
                        listener.completed(result, error);
                    }
                });
    }

    /**
     * This method is provided to allow callers to run the command and wait on the result.
     * You should probably not call this method on the main thread.
     * You should also limit this to fast local commands.
     *
     * @return
     */
    public T runSynchronously() {
        final long startTime = System.nanoTime();
        final SettableFuture<T> syncResult = SettableFuture.create();
        final SettableFuture<Throwable> syncError = SettableFuture.create();

        run(new Listener<T>() {
            @Override
            public void progress(String output, int outputType, int percentComplete) {
                // Do nothing
            }

            @Override
            public void completed(T result, Throwable error) {
                syncResult.set(result);
                syncError.set(error);
            }
        });

        try {
            Throwable error = syncError.get();
            if (error != null) {
                if (error instanceof RuntimeException) {
                    throw (RuntimeException) error;
                } else {
                    // Wrap the exception
                    throw new ToolException(ToolException.KEY_TF_BAD_EXIT_CODE, error);
                }
            } else {
                return syncResult.get();
            }
        } catch (InterruptedException e) {
            logger.error("CMD: failure", e);
            throw new ToolException(ToolException.KEY_TF_BAD_EXIT_CODE, e);
        } catch (ExecutionException e) {
            logger.error("CMD: failure", e);
            throw new ToolException(ToolException.KEY_TF_BAD_EXIT_CODE, e);
        } finally {
            final long endTime = System.nanoTime();
            logger.info(Long.toString(endTime - startTime) + "(ns) - elapsed time for "
                    + this.getArgumentBuilder().toString());
            Debug.println("", Long.toString(endTime - startTime) + "(ns) - elapsed time for "
                    + this.getArgumentBuilder().toString());
        }
    }

    public abstract T parseOutput(final String stdout, final String stderr);

    /**
     * Default method for parsing return code that can be overridden if need be
     *
     * @param returnCode
     * @return returnCode
     */
    public int interpretReturnCode(final int returnCode) {
        return returnCode;
    }

    protected NodeList evaluateXPath(final String stdout, final String xpathQuery) {
        if (StringUtils.isEmpty(stdout)) {
            return null;
        }

        // Skip over any lines (like WARNing lines) that come before the xml tag
        // Example:
        // WARN -- Unable to construct Telemetry Client
        // <?xml ...
        final InputSource xmlInput;
        final int xmlStart = stdout.indexOf(XML_PREFIX);
        if (xmlStart > 0) {
            xmlInput = new InputSource(new StringReader(stdout.substring(xmlStart)));
        } else {
            xmlInput = new InputSource(new StringReader(stdout));
        }

        final XPath xpath = XPathFactory.newInstance().newXPath();
        try {
            final Object result = xpath.evaluate(xpathQuery, xmlInput, XPathConstants.NODESET);
            if (result != null && result instanceof NodeList) {
                return (NodeList) result;
            }
        } catch (final XPathExpressionException inner) {
            throw new ToolParseFailureException(inner);
        }

        throw new ToolParseFailureException();
    }

    protected String getXPathAttributeValue(final NamedNodeMap attributeMap, final String attributeName) {
        String value = StringUtils.EMPTY;
        if (attributeMap != null) {
            final Node node = attributeMap.getNamedItem(attributeName);
            if (node != null) {
                value = node.getNodeValue();
            }
        }

        return value;
    }

    protected String[] getLines(final String buffer) {
        return getLines(buffer, true);
    }

    protected String[] getLines(final String buffer, final boolean skipWarnings) {
        final List<String> lines = new ArrayList<String>(Arrays.asList(buffer.replace("\r\n", "\n").split("\n")));
        if (skipWarnings) {
            while (lines.size() > 0 && StringUtils.startsWithIgnoreCase(lines.get(0), WARNING_PREFIX)) {
                lines.remove(0);
            }
        }
        return lines.toArray(new String[lines.size()]);
    }

    /**
     * This method is used by Checkin and CreateBranch to parse out the changeset number.
     * @param buffer
     * @return
     */
    protected String getChangesetNumber(final String buffer) {
        // parse output for changeset number
        String changesetNumber = StringUtils.EMPTY;
        final Matcher matcher = CHANGESET_NUMBER_PATTERN.matcher(buffer);
        if (matcher.find()) {
            changesetNumber = matcher.group(1);
            logger.info("Changeset '" + changesetNumber + "' was created");
        } else {
            logger.info("Changeset pattern not found in buffer: " + buffer);
        }
        return changesetNumber;
    }

    /**
     * This method evaluates a line of output to see if it contains something that is expected.
     * If not, it returns false.
     * There are 3 kinds of expected lines:
     * 1) lines that begin with an expected prefix
     * 2) lines that contain a folder path (/path/path:)
     * 3) empty lines
     * All other types of lines are unexpected.
     */
    protected boolean isOutputLineExpected(final String line, final String[] expectedPrefixes,
            final boolean filePathsAreExpected) {
        final String trimmed = line != null ? line.trim() : null;
        if (StringUtils.isNotEmpty(trimmed)) {
            // If we are expecting file paths, check for a file path pattern (ex. /path/path2:)
            if (filePathsAreExpected && isFilePath(line)) {
                // This matched our file path pattern, so it is expected
                return true;
            }

            // Next, check for one of the expected prefixes
            if (expectedPrefixes != null) {
                for (final String prefix : expectedPrefixes) {
                    if (StringUtils.startsWithIgnoreCase(line, prefix)) {
                        // The line starts with an expected prefix so it is expected
                        return true;
                    }
                }
            }

            // The line is not empty and does not contain anything we expect
            // So, it is probably an error.
            return false;
        }

        // Just return true for empty lines
        return true;
    }

    protected boolean isFilePath(final String line) {
        if (StringUtils.endsWith(line, ":")) {
            // File paths are different on different OSes
            if (StringUtils.containsAny(line, "\\/")) {
                return true;
            }
        }
        return false;
    }

    protected String getFilePath(final String path, final String filename, final String pathRoot) {
        // If the path still has a ':' at the end, remove it
        String folderPath = StringUtils.removeEnd(path, ":");
        // If the path isn't rooted, add in the root
        if (!Path.isAbsolute(folderPath) && StringUtils.isNotEmpty(pathRoot)) {
            folderPath = Path.combine(pathRoot, folderPath);
        }

        return Path.combine(folderPath, filename);
    }

    protected void throwIfError(final String stderr) {
        if (StringUtils.isNotEmpty(stderr)) {
            //TODO what kind of exception should this be?
            throw new RuntimeException(stderr);
        }
    }

}