package hudson.plugins.tfs;
import com.cloudbees.plugins.credentials.common.StandardUsernamePasswordCredentials;
import com.microsoft.tfs.core.clients.versioncontrol.specs.version.ChangesetVersionSpec;
import com.microsoft.tfs.core.clients.versioncontrol.specs.version.DateVersionSpec;
import com.microsoft.tfs.core.clients.versioncontrol.specs.version.VersionSpec;
import hudson.Extension;
import hudson.FilePath;
import hudson.Launcher;
import hudson.Util;
import hudson.model.AbstractBuild;
import hudson.model.AbstractProject;
import hudson.model.BuildListener;
import hudson.model.Computer;
import hudson.model.Node;
import hudson.model.ParametersAction;
import hudson.model.Run;
import hudson.model.TaskListener;
import hudson.plugins.tfs.actions.CheckoutAction;
import hudson.plugins.tfs.actions.RemoveWorkspaceAction;
import hudson.plugins.tfs.browsers.TeamFoundationServerRepositoryBrowser;
import hudson.plugins.tfs.browsers.TeamSystemWebAccessBrowser;
import hudson.plugins.tfs.model.ChangeSet;
import hudson.plugins.tfs.model.CredentialsConfigurer;
import hudson.plugins.tfs.model.CredentialsConfigurerDescriptor;
import hudson.plugins.tfs.model.ManualCredentialsConfigurer;
import hudson.plugins.tfs.model.Project;
import hudson.plugins.tfs.model.Server;
import hudson.plugins.tfs.model.WorkspaceConfiguration;
import hudson.plugins.tfs.util.BuildVariableResolver;
import hudson.plugins.tfs.util.BuildWorkspaceConfigurationRetriever;
import hudson.plugins.tfs.util.BuildWorkspaceConfigurationRetriever.BuildWorkspaceConfiguration;
import hudson.scm.ChangeLogParser;
import hudson.scm.PollingResult;
import hudson.scm.PollingResult.Change;
import hudson.scm.RepositoryBrowser;
import hudson.scm.RepositoryBrowsers;
import hudson.scm.SCM;
import hudson.scm.SCMDescriptor;
import hudson.scm.SCMRevisionState;
import hudson.util.ComboBoxModel;
import hudson.util.FormValidation;
import hudson.util.LogTaskListener;
import hudson.util.Scrambler;
import hudson.util.Secret;
import hudson.util.VariableResolver;
import net.sf.json.JSONObject;
import org.apache.commons.lang.StringUtils;
import org.kohsuke.stapler.DataBoundConstructor;
import org.kohsuke.stapler.DataBoundSetter;
import org.kohsuke.stapler.QueryParameter;
import org.kohsuke.stapler.StaplerRequest;
import javax.annotation.CheckForNull;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.regex.Pattern;
import static hudson.Util.fixEmpty;
/**
* SCM for Microsoft Team Foundation Server.
*
* @author Erik Ramfelt
*/
public class TeamFoundationServerScm extends SCM {
public static final String WORKSPACE_ENV_STR = "TFS_WORKSPACE";
public static final String WORKFOLDER_ENV_STR = "TFS_WORKFOLDER";
public static final String PROJECTPATH_ENV_STR = "TFS_PROJECTPATH";
public static final String SERVERURL_ENV_STR = "TFS_SERVERURL";
public static final String USERNAME_ENV_STR = "TFS_USERNAME";
public static final String WORKSPACE_CHANGESET_ENV_STR = "TFS_CHANGESET";
private static final String VERSION_SPEC = "VERSION_SPEC";
private final String serverUrl;
private final String projectPath;
private Collection<String> cloakedPaths;
private String localPath;
private final String workspaceName;
private @Deprecated String userPassword;
private Secret password;
private String userName;
private CredentialsConfigurer credentialsConfigurer;
private boolean useUpdate;
private TeamFoundationServerRepositoryBrowser repositoryBrowser;
private transient String normalizedWorkspaceName;
private transient String workspaceChangesetVersion;
private static final Logger logger = Logger.getLogger(TeamFoundationServerScm.class.getName());
/**
* Constructor used for unit tests.
*
* @param serverUrl the URL to the team project collection
* @param projectPath the path in TFVC to download from
* @param workspaceName the name (or expression) to use when mapping the workspace
*/
TeamFoundationServerScm(String serverUrl, String projectPath, String workspaceName) {
this(serverUrl, projectPath, workspaceName, null, null);
}
/**
* Constructor used during serialization (and a few tests).
*
* WARNING: do NOT add parameters to this constructor when adding fields for new settings.
* Instead, add a setter annotated with {@link DataBoundSetter} in the "Bean properties" section.
* See {@link #setLocalPath(String)} for an example.
*
* @param serverUrl the URL to the team project collection
* @param projectPath the path in TFVC to download from
* @param workspaceName the name (or expression) to use when mapping the workspace
* @param userName the name of the user account to use to talk to TFS/Team Services
* @param password the password or personal access token to use to talk to TFS/Team Services
*/
@DataBoundConstructor
public TeamFoundationServerScm(String serverUrl, String projectPath, String workspaceName, String userName, Secret password) {
this.serverUrl = serverUrl;
this.projectPath = projectPath;
this.workspaceName = (Util.fixEmptyAndTrim(workspaceName) == null ? "Hudson-${JOB_NAME}-${NODE_NAME}" : workspaceName);
this.userName = userName;
this.password = password;
}
@SuppressWarnings("unused" /* Migrate legacy data */)
private Object readResolve() {
if (password == null && userPassword != null) {
password = Secret.fromString(Scrambler.descramble(userPassword));
userPassword = null;
}
if (userName != null && password != null) {
credentialsConfigurer = new ManualCredentialsConfigurer(userName, password);
}
return this;
}
// Bean properties needed for job configuration
public String getServerUrl() {
return serverUrl;
}
public String getWorkspaceName() {
return workspaceName;
}
public String getProjectPath() {
return projectPath;
}
public String getLocalPath() {
return (Util.fixEmptyAndTrim(localPath) == null ? "." : localPath);
}
@DataBoundSetter
public void setLocalPath(final String localPath) {
this.localPath = localPath;
}
public boolean isUseUpdate() {
return useUpdate;
}
@DataBoundSetter
public void setUseUpdate(final boolean useUpdate) {
this.useUpdate = useUpdate;
}
public String getUserPassword() {
return Secret.toString(password);
}
public Secret getPassword() {
return password;
}
public String getUserName() {
return userName;
}
public CredentialsConfigurer getCredentialsConfigurer() {
if (credentialsConfigurer == null) {
credentialsConfigurer = new ManualCredentialsConfigurer(userName, password);
}
return credentialsConfigurer;
}
@DataBoundSetter
public void setCredentialsConfigurer(final CredentialsConfigurer credentialsConfigurer) {
this.credentialsConfigurer = credentialsConfigurer;
}
public String getCloakedPaths() {
return serializeCloakedPathCollectionToString(this.cloakedPaths);
}
@DataBoundSetter
public void setCloakedPaths(final String cloakedPaths) {
this.cloakedPaths = splitCloakedPaths(cloakedPaths);
}
// Bean properties END
static String serializeCloakedPathCollectionToString(final Collection<String> cloakedPaths) {
return cloakedPaths == null ? StringUtils.EMPTY : StringUtils.join(cloakedPaths, "\n");
}
String getWorkspaceName(AbstractBuild<?,?> build, Computer computer) {
normalizedWorkspaceName = workspaceName;
if (build != null) {
normalizedWorkspaceName = substituteBuildParameter(build, normalizedWorkspaceName);
normalizedWorkspaceName = Util.replaceMacro(normalizedWorkspaceName, new BuildVariableResolver(build.getProject(), computer));
}
normalizedWorkspaceName = normalizedWorkspaceName.replaceAll("[\"/:<>\\|\\*\\?]+", "_");
normalizedWorkspaceName = normalizedWorkspaceName.replaceAll("[\\.\\s]+$", "_");
return normalizedWorkspaceName;
}
public String getServerUrl(Run<?,?> run) {
return substituteBuildParameter(run, serverUrl);
}
String getProjectPath(Run<?,?> run) {
return Util.replaceMacro(substituteBuildParameter(run, projectPath), new BuildVariableResolver(run.getParent()));
}
Collection<String> getCloakedPaths(final Run<?, ?> run) {
final List<String> paths = new ArrayList<String>();
if (cloakedPaths != null) {
final BuildVariableResolver resolver = new BuildVariableResolver(run.getParent());
for (final String cloakedPath : cloakedPaths) {
final String path = substituteBuildParameter(run, cloakedPath);
final String enhancedPath = Util.replaceMacro(path, resolver);
paths.add(enhancedPath);
}
}
return paths;
}
private String substituteBuildParameter(Run<?,?> run, String text) {
if (run instanceof AbstractBuild<?, ?>){
AbstractBuild<?,?> build = (AbstractBuild<?, ?>) run;
if (build.getAction(ParametersAction.class) != null) {
return build.getAction(ParametersAction.class).substitute(build, text);
}
}
return text;
}
static Collection<String> splitCloakedPaths(final String cloakedPaths) {
final List<String> cloakedPathsList = new ArrayList<String>();
if (cloakedPaths != null && cloakedPaths.length() > 0) {
final StringBuilder cloakedPath = new StringBuilder(cloakedPaths.length());
for (final char character : cloakedPaths.toCharArray()) {
switch (character) {
case '\n':
if (cloakedPath.length() > 0) {
cloakedPathsList.add(cloakedPath.toString().trim());
cloakedPath.setLength(0);
}
break;
default:
cloakedPath.append(character);
break;
}
}
if (cloakedPath.length() > 0) {
cloakedPathsList.add(cloakedPath.toString().trim());
}
}
return cloakedPathsList;
}
@Override
public boolean checkout(AbstractBuild<?, ?> build, Launcher launcher, FilePath workspaceFilePath, BuildListener listener, File changelogFile) throws IOException, InterruptedException {
Server server = createServer(launcher, listener, build);
try {
WorkspaceConfiguration workspaceConfiguration = new WorkspaceConfiguration(server.getUrl(), getWorkspaceName(build, Computer.currentComputer()), getProjectPath(build), getCloakedPaths(build), getLocalPath());
final AbstractBuild<?, ?> previousBuild = build.getPreviousBuild();
// Check if the configuration has changed
if (previousBuild != null) {
BuildWorkspaceConfiguration nodeConfiguration = new BuildWorkspaceConfigurationRetriever().getLatestForNode(build.getBuiltOn(), previousBuild);
if ((nodeConfiguration != null) &&
nodeConfiguration.workspaceExists()
&& (! workspaceConfiguration.equals(nodeConfiguration))) {
listener.getLogger().println("Deleting workspace as the configuration has changed since a build was performed on this computer.");
new RemoveWorkspaceAction(workspaceConfiguration.getWorkspaceName()).remove(server);
nodeConfiguration.setWorkspaceWasRemoved();
nodeConfiguration.save();
}
}
build.addAction(workspaceConfiguration);
VariableResolver<String> buildVariableResolver = build.getBuildVariableResolver();
String singleVersionSpec = buildVariableResolver.resolve(VERSION_SPEC);
final String projectPath = workspaceConfiguration.getProjectPath();
final Project project = server.getProject(projectPath);
final int changeSet = recordWorkspaceChangesetVersion(build, listener, project, projectPath, singleVersionSpec);
CheckoutAction action = new CheckoutAction(workspaceConfiguration.getWorkspaceName(), workspaceConfiguration.getProjectPath(), workspaceConfiguration.getCloakedPaths(), workspaceConfiguration.getWorkfolder(), isUseUpdate());
List<ChangeSet> list;
if (StringUtils.isNotEmpty(singleVersionSpec)) {
list = action.checkoutBySingleVersionSpec(server, workspaceFilePath, singleVersionSpec);
}
else {
final VersionSpec previousBuildVersionSpec = determineVersionSpecFromBuild(previousBuild, 1, changeSet);
final ChangesetVersionSpec currentBuildVersionSpec = new ChangesetVersionSpec(changeSet);
list = action.checkout(server, workspaceFilePath, previousBuildVersionSpec, currentBuildVersionSpec);
}
ChangeSetWriter writer = new ChangeSetWriter();
writer.write(list, changelogFile);
} finally {
server.close();
}
return true;
}
static VersionSpec determineVersionSpecFromBuild(final AbstractBuild<?, ?> build, final int offset, final int maximumChangeSetNumber) {
final VersionSpec result;
if (build != null) {
final TFSRevisionState revisionState = build.getAction(TFSRevisionState.class);
if (revisionState != null) {
final int changeSetNumber = revisionState.changesetVersion + offset;
if (changeSetNumber <= maximumChangeSetNumber) {
result = new ChangesetVersionSpec(changeSetNumber);
} else {
result = null;
}
} else {
result = null;
}
}
else {
result = null;
}
return result;
}
int recordWorkspaceChangesetVersion(final AbstractBuild<?, ?> build, final BuildListener listener, final Project project, final String projectPath, final String singleVersionSpec) throws IOException, InterruptedException {
final VersionSpec workspaceVersion;
if (!StringUtils.isEmpty(singleVersionSpec)) {
workspaceVersion = VersionSpec.parseSingleVersionFromSpec(singleVersionSpec, null);
}
else {
workspaceVersion = new DateVersionSpec(build.getTimestamp());
}
int buildChangeset;
setWorkspaceChangesetVersion(null);
buildChangeset = project.getRemoteChangesetVersion(workspaceVersion);
setWorkspaceChangesetVersion(Integer.toString(buildChangeset, 10));
// by adding this action, we prevent calcRevisionsFromBuild() from being called
build.addAction(new TFSRevisionState(buildChangeset, projectPath));
return buildChangeset;
}
void setWorkspaceChangesetVersion(String workspaceChangesetVersion) {
this.workspaceChangesetVersion = workspaceChangesetVersion;
}
@Override
public boolean pollChanges(AbstractProject hudsonProject, Launcher launcher, FilePath workspace, TaskListener listener) throws IOException, InterruptedException {
Run<?,?> lastRun = hudsonProject.getLastBuild();
if (lastRun == null) {
return true;
} else {
Server server = createServer(launcher, listener, lastRun);
try {
return (server.getProject(getProjectPath(lastRun)).getDetailedHistoryWithoutCloakedPaths(
lastRun.getTimestamp(),
Calendar.getInstance(),
getCloakedPaths(lastRun)
).size() > 0);
} finally {
server.close();
}
}
}
@Override
public boolean processWorkspaceBeforeDeletion(AbstractProject<?, ?> project, FilePath workspace, Node node) throws IOException, InterruptedException {
Run<?,?> lastRun = project.getLastBuild();
if ((lastRun == null) || !(lastRun instanceof AbstractBuild<?, ?>)) {
return true;
}
// Due to an error in Hudson core (pre 1.321), null was sent in for all invocations of this method
// Therefore we try to work around the problem, and see if its only built on one node or not.
if (node == null) {
while (lastRun != null) {
AbstractBuild<?,?> build = (AbstractBuild<?, ?>) lastRun;
Node buildNode = build.getBuiltOn();
if (node == null) {
node = buildNode;
} else {
if (!buildNode.getNodeName().equals(node.getNodeName())) {
logger.warning("Could not wipe out workspace as there is no way of telling what Node the request is for. Please upgrade Hudson to a newer version.");
return false;
}
}
lastRun = lastRun.getPreviousBuild();
}
if (node == null) {
return true;
}
lastRun = project.getLastBuild();
}
BuildWorkspaceConfiguration configuration = new BuildWorkspaceConfigurationRetriever().getLatestForNode(node, lastRun);
if ((configuration != null) && configuration.workspaceExists()) {
LogTaskListener listener = new LogTaskListener(logger, Level.INFO);
Launcher launcher = node.createLauncher(listener);
Server server = createServer(launcher, listener, lastRun);
try {
if (new RemoveWorkspaceAction(configuration.getWorkspaceName()).remove(server)) {
configuration.setWorkspaceWasRemoved();
configuration.save();
}
} finally {
server.close();
}
}
return true;
}
protected Server createServer(final Launcher launcher, final TaskListener taskListener, Run<?,?> run) throws IOException {
final CredentialsConfigurer credentialsConfigurer = getCredentialsConfigurer();
final String collectionUri = getServerUrl(run);
final StandardUsernamePasswordCredentials credentials = credentialsConfigurer.getCredentials(collectionUri);
return Server.create(launcher, taskListener, collectionUri, credentials, null, null);
}
@Override
public boolean requiresWorkspaceForPolling() {
return false;
}
@Override
public boolean supportsPolling() {
return true;
}
@Override
public ChangeLogParser createChangeLogParser() {
return new ChangeSetReader();
}
@Override
public FilePath getModuleRoot(FilePath workspace) {
return workspace.child(getLocalPath());
}
@Override
public TeamFoundationServerRepositoryBrowser getBrowser() {
return repositoryBrowser;
}
/**
*
* @return a new TeamSystemWebAccessBrowser even if no repository browser (value in UI is Auto) is
* configured since its the only implementation that exists anyway
*/
@Override
public @CheckForNull RepositoryBrowser<?> guessBrowser() {
return new TeamSystemWebAccessBrowser(serverUrl);
}
// Convenience method for tests.
public void setRepositoryBrowser(final TeamFoundationServerRepositoryBrowser repositoryBrowser) {
this.repositoryBrowser = repositoryBrowser;
}
@Override
public void buildEnvVars(AbstractBuild<?,?> build, Map<String, String> env) {
super.buildEnvVars(build, env);
if (normalizedWorkspaceName != null) {
env.put(WORKSPACE_ENV_STR, normalizedWorkspaceName);
}
if (env.containsKey("WORKSPACE")) {
env.put(WORKFOLDER_ENV_STR, env.get("WORKSPACE") + File.separator + getLocalPath());
}
if (projectPath != null) {
env.put(PROJECTPATH_ENV_STR, projectPath);
}
if (serverUrl != null) {
env.put(SERVERURL_ENV_STR, serverUrl);
}
if (userName != null) {
env.put(USERNAME_ENV_STR, userName);
}
if (workspaceChangesetVersion != null && workspaceChangesetVersion.length() > 0) {
env.put(WORKSPACE_CHANGESET_ENV_STR, workspaceChangesetVersion);
}
}
@Override
public DescriptorImpl getDescriptor() {
return (DescriptorImpl)super.getDescriptor();
}
@Extension
public static class DescriptorImpl extends SCMDescriptor<TeamFoundationServerScm> {
public static final Pattern WORKSPACE_NAME_REGEX = Pattern.compile("[^\"/:<>\\|\\*\\?]+[^\\s\\.\"/:<>\\|\\*\\?]$", Pattern.CASE_INSENSITIVE);
public static final Pattern PROJECT_PATH_REGEX = Pattern.compile("^\\$\\/.*", Pattern.CASE_INSENSITIVE);
public static final Pattern CLOAKED_PATHS_REGEX = Pattern.compile("\\s*\\$[^\\n;]+(\\s*[\\n]\\s*\\$[^\\n;]+){0,}\\s*", Pattern.CASE_INSENSITIVE);
private transient String tfExecutable;
public DescriptorImpl() {
super(TeamFoundationServerScm.class, TeamFoundationServerRepositoryBrowser.class);
load();
}
@Override
public SCM newInstance(StaplerRequest req, JSONObject formData) throws FormException {
TeamFoundationServerScm scm = (TeamFoundationServerScm) super.newInstance(req, formData);
scm.repositoryBrowser = RepositoryBrowsers.createInstance(TeamFoundationServerRepositoryBrowser.class,req,formData,"browser");
// TODO: is there a more polymorphic way of doing this?
if (scm.credentialsConfigurer instanceof ManualCredentialsConfigurer) {
// ManualCredentialsConfigurer has its fields "transient"; transfer the values here
// for backward-compatibility
final ManualCredentialsConfigurer manualCredentialsConfigurer = (ManualCredentialsConfigurer) scm.credentialsConfigurer;
scm.userName = manualCredentialsConfigurer.getUserName();
scm.password = manualCredentialsConfigurer.getPassword();
}
return scm;
}
private FormValidation doRegexCheck(final Pattern[] regexArray,
final String noMatchText, final String nullText, String value) {
value = fixEmpty(value);
if (value == null) {
if (nullText == null) {
return FormValidation.ok();
} else {
return FormValidation.error(nullText);
}
}
for (Pattern regex : regexArray) {
if (regex.matcher(value).matches()) {
return FormValidation.ok();
}
}
return FormValidation.error(noMatchText);
}
public ComboBoxModel doFillServerUrlItems() {
final TeamPluginGlobalConfig pluginGlobalConfig = TeamPluginGlobalConfig.get();
final List<TeamCollectionConfiguration> collectionConfigurations = pluginGlobalConfig.getCollectionConfigurations();
final ComboBoxModel result = new ComboBoxModel(collectionConfigurations.size());
for (final TeamCollectionConfiguration collectionConfiguration : collectionConfigurations) {
result.add(collectionConfiguration.getCollectionUrl());
}
return result;
}
public FormValidation doProjectPathCheck(@QueryParameter final String value) {
return doRegexCheck(new Pattern[]{PROJECT_PATH_REGEX},
"Project path must begin with '$/'.",
"Project path is mandatory.", value );
}
public FormValidation doWorkspaceNameCheck(@QueryParameter final String value) {
return doRegexCheck(new Pattern[]{WORKSPACE_NAME_REGEX},
"Workspace name cannot end with a space or period, and cannot contain any of the following characters: \"/:<>|*?",
"Workspace name is mandatory", value);
}
public FormValidation doCloakedPathsCheck(@QueryParameter final String value) {
return doRegexCheck(new Pattern[]{CLOAKED_PATHS_REGEX},
"Each cloaked path must begin with '$/'. Multiple paths must be separated by blank lines.",
null, value );
}
public List<CredentialsConfigurerDescriptor> getCredentialsConfigurerDescriptors() {
return CredentialsConfigurer.all();
}
@Override
public boolean configure(StaplerRequest req, JSONObject formData) throws FormException {
save();
return true;
}
@Override
public String getDisplayName() {
return "Team Foundation Version Control (TFVC)";
}
}
@Override
public SCMRevisionState calcRevisionsFromBuild(AbstractBuild<?, ?> build,
Launcher launcher, TaskListener listener) throws IOException,
InterruptedException {
/*
* This method does nothing, since the work has already been done in
* the checkout() method, as per the documentation:
* """
* As an optimization, SCM implementation can choose to compute SCMRevisionState
* and add it as an action during check out, in which case this method will not called.
* """
*/
return null;
}
@Override
protected PollingResult compareRemoteRevisionWith(
AbstractProject<?, ?> project, Launcher launcher,
FilePath workspace, TaskListener listener, SCMRevisionState baseline)
throws IOException, InterruptedException {
final Launcher localLauncher = launcher != null ? launcher : new Launcher.LocalLauncher(listener);
if (!(baseline instanceof TFSRevisionState))
{
// This plugin was just upgraded, we don't yet have a new-style baseline,
// so we perform an old-school poll
boolean shouldBuild = pollChanges(project, localLauncher, workspace, listener);
return shouldBuild ? PollingResult.BUILD_NOW : PollingResult.NO_CHANGES;
}
final TFSRevisionState tfsBaseline = (TFSRevisionState) baseline;
if (!projectPath.equalsIgnoreCase(tfsBaseline.projectPath))
{
// There's no PollingResult.INCOMPARABLE, so we use the next closest thing
return PollingResult.BUILD_NOW;
}
Run<?, ?> build = project.getLastBuild();
final Server server = createServer(localLauncher, listener, build);
final Project tfsProject = server.getProject(projectPath);
try {
final ChangeSet latest = tfsProject.getLatestUncloakedChangeset(tfsBaseline.changesetVersion, cloakedPaths);
final TFSRevisionState tfsRemote =
(latest != null)
? new TFSRevisionState(latest.getVersion(), projectPath)
: tfsBaseline;
// TODO: we could return INSIGNIFICANT if all the changesets
// contain the string "***NO_CI***" at the end of their comment
final Change change =
tfsBaseline.changesetVersion == tfsRemote.changesetVersion
? Change.NONE
: Change.SIGNIFICANT;
return new PollingResult(tfsBaseline, tfsRemote, change);
} catch (final Exception e) {
e.printStackTrace(listener.fatalError(e.getMessage()));
return PollingResult.NO_CHANGES;
} finally {
server.close();
}
}
}