Crucible SCM Plugin Tutorial

On this page:

Crucible SCM Plugins

Crucible SCM modules are plugins that make version control systems accessible to Crucible. An SCM plugin can be used to give Crucible the ability to work with a custom version control system that is not supported out of the box. SCM plugins are independent from FishEye's version control integrations and allow Crucible to run standalone. Crucible ships with a number of built-in SCM plugins, including Subversion and Perforce.

In this section we will implement a new Crucible SCM Plugin and explore Crucible's public SCM API. The example builds a module that exposes the underlying file system as the "repository", so that users can perform reviews of files on the server file system.

Creating a Project

To start, we use the Atlassian Plugins SDK to create a new plugin project. If you haven't done so already, download and install the SDK first.

$ atlas-create-fecru-plugin
Executing: /Users/ervzijst/opt/atlassian-plugin-sdk-3.0-beta7/apache-maven/bin/mvn com.atlassian.maven.plugins:maven-fecru-plugin:3.0-beta7:create
[INFO] Scanning for projects...
...
[INFO] ------------------------------------------------------------------------
[INFO] Building Maven Default Project
[INFO]    task-segment: [com.atlassian.maven.plugins:maven-fecru-plugin:3.0-beta7:create] (aggregator-style)
[INFO] ------------------------------------------------------------------------
...
[INFO] [fecru:create]
...
[INFO] Setting property: classpath.resource.loader.class => 'org.codehaus.plexus.velocity.ContextClassLoaderResourceLoader'.
[INFO] Setting property: velocimacro.messages.on => 'false'.
[INFO] Setting property: resource.loader => 'classpath'.
[INFO] Setting property: resource.manager.logwhenfound => 'false'.
[INFO] [archetype:generate]
[INFO] Generating project in Interactive mode
...
Define value for groupId: : com.atlassian.crucible.example.scm
Define value for artifactId: : example-scm-plugin
Define value for version:  1.0-SNAPSHOT: :
Define value for package:  com.atlassian.crucible.example.scm: :
Confirm properties configuration:
groupId: com.atlassian.crucible.example.scm
artifactId: example-scm-plugin
version: 1.0-SNAPSHOT
package: com.atlassian.crucible.example.scm
 Y: :
[INFO] ----------------------------------------------------------------------------
[INFO] Using following parameters for creating OldArchetype: fecru-plugin-archetype:3.0-beta7
[INFO] ----------------------------------------------------------------------------
[INFO] Parameter: groupId, Value: com.atlassian.crucible.example.scm
[INFO] Parameter: packageName, Value: com.atlassian.crucible.example.scm
[INFO] Parameter: basedir, Value: /Users/ervzijst/workspace
[INFO] Parameter: package, Value: com.atlassian.crucible.example.scm
[INFO] Parameter: version, Value: 1.0-SNAPSHOT
[INFO] Parameter: artifactId, Value: example-scm-plugin
[INFO] ********************* End of debug info from resources from generated POM ***********************
[INFO] OldArchetype created in dir: /Users/ervzijst/workspace/example-scm-plugin
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESSFUL
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 1 minute 57 seconds
[INFO] Finished at: Fri Oct 02 10:12:05 EST 2009
[INFO] Final Memory: 28M/50M
[INFO] ------------------------------------------------------------------------

Note that this step interactively asks you to supply the groupId, artifactId, package and version number you want to use for your new plugin.

This creates a new project that has a dependency on atlassian-fisheye-api. This library contains the basic API components required by plugins. It also comes with dependencies on atlassian-crucible-scmutils (which provides a collection of utility class that helps you spawn processes outside JVM – which can be useful for SCM plugins that fork command line binaries to talk to their repositories) as well as atlassian-plugins-core.

The pom.xml looks something like:

pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">

    <modelVersion>4.0.0</modelVersion>
    <groupId>com.atlassian.crucible.example.scm</groupId>
    <artifactId>example-scm-plugin</artifactId>
    <version>1.0-SNAPSHOT</version>

    <organization>
        <name>Example Company</name>
        <url>http://www.example.com/</url>
    </organization>

    <name>example-scm-plugin</name>
    <description>This is the com.atlassian.crucible.example.scm:example-scm-plugin plugin for Atlassian FishEye/Crucible.</description>
    <packaging>atlassian-plugin</packaging>

    <dependencies>
        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <version>3.8.1</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>javax.servlet</groupId>
            <artifactId>servlet-api</artifactId>
            <version>2.4</version>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>com.atlassian.fisheye</groupId>
            <artifactId>atlassian-fisheye-api</artifactId>
            <version>${fecru.version}</version>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>com.atlassian.crucible</groupId>
            <artifactId>atlassian-crucible-scmutils</artifactId>
            <version>${fecru.version}</version>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>com.atlassian.plugins</groupId>
            <artifactId>atlassian-plugins-core</artifactId>
            <version>2.3.0</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>com.atlassian.maven.plugins</groupId>
                <artifactId>maven-fecru-plugin</artifactId>
                <version>3.0-beta7</version>
                <extensions>true</extensions>
                <configuration>
                    <productVersion>${fecru.version}</productVersion>
                    <productDataVersion>${fecru.data.version}</productDataVersion>
                </configuration>
            </plugin>
            <plugin>
                <artifactId>maven-compiler-plugin</artifactId>
                <configuration>
                    <source>1.5</source>
                    <target>1.5</target>
                </configuration>
            </plugin>
        </plugins>
    </build>
    <properties>
        <fecru.version>2.0.5-429</fecru.version>
        <fecru.data.version>2.0.4.1-SNAPSHOT</fecru.data.version>
    </properties>
</project>

IDEA Users

If you are using IntelliJ for development, then depending on the version of IDEA, you might need to run atlas-mvn idea:idea to generate the project files. Opening the pom file directly is known to miss the parent dependencies.

Crucible SCM Plugin API

Crucible's public API can be browsed online and contains the functionality needed to develop a custom SCM plugin in the package com.atlassian.crucible.scm. It consists of a set of interfaces, some of which are optional, for browsing a repository, accessing its directories, retrieving file contents and exploring changes between revisions.

At the very least, your SCM plugin should implement the com.atlassian.crucible.scm.SCMModule interface that defines the new plugin. The module is then used to create one or more repository instances:

package com.atlassian.scm;

import com.atlassian.crucible.scm.SCMModule;
import com.atlassian.crucible.scm.SCMRepository;
import com.atlassian.plugin.ModuleDescriptor;

import java.util.Collection;
import java.util.Collections;

public class ExampleSCMModule implements SCMModule {

    private ModuleDescriptor moduleDescriptor;
    private List<SCMRepository> repos = Collections.emptyList();

    public String getName() {
        return "Example File System SCM.";
    }

    public Collection<? extends SCMRepository> getRepositories() {
        return repos;
    }

    public void setModuleDescriptor(ModuleDescriptor moduleDescriptor) {
        this.moduleDescriptor = moduleDescriptor;
    }

    public ModuleDescriptor getModuleDescriptor() {
        return moduleDescriptor;
    }
}

When your module is instantiated, Crucible passes a ModuleDescriptor instance to it containing information about the plugin. The getRepositories() method returns the repositories offered by this plugin. Currently we're returning an empty collection.

To be able to use the Crucible administration console to configure our plugin and specifiy the locations of the repositories we want to use, we will also implement the Configurable interface that allows for the injection of a custom configuration bean (by implementing SimpleConfiguration) whose properties can be manipulated through the administration interface for which we will write a small servlet. In our custom configuration bean we'll add a property for the base path or root directory of the file system based repositories we want to offer.

The plugin configuration is written to disk and fed to our SCMModule when Crucible starts up. Our plugin is responsible for generating and parsing that data, so we're free to choose the format. The ModuleConfigurationStore provides persistent storage and will automatically be injected into our plugin if we create a constructor that takes it as an argument. For the serialization, let's use simple XML serialization through XStream (using XStream is convenient as it is one of the dependencies for atlassian-crucible-scmutils):

package com.atlassian.scm;

import com.atlassian.fisheye.plugins.scm.utils.SimpleConfiguration;

public class ExampleConfiguration implements SimpleConfiguration {

    private String name;
    private String basePath;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getBasePath() {
        return basePath;
    }

    public void setBasePath(String basePath) {
        this.basePath = basePath;
    }
}

Now we make the required changes to our SCMModule to read and write the configuration:

public class ExampleSCMModule implements SCMModule, Configurable<List<ExampleConfiguration>> {

    private ModuleDescriptor moduleDescriptor;
    private ModuleConfigurationStore store;

    public ExampleSCMModule(ModuleConfigurationStore store) {
        this.store = store;
    }

    [...]

    public List<ExampleConfiguration> getConfiguration() {
        byte[] configData = store.getConfiguration(moduleDescriptor);
        if (configData != null) {
            try {
                return (List<ExampleConfiguration>)getXStream().fromXML(new String(configData, "UTF8"));
            } catch (Exception e) {
                throw new RuntimeException("Error reading configuration:" + configData, e);
            }
        }
        return new ArrayList<ExampleConfiguration>();
    }

    public void setConfiguration(List<ExampleConfiguration> config) {
        try {
            store.putConfiguration(moduleDescriptor, getXStream().toXML(config).getBytes("UTF8"));
        } catch (UnsupportedEncodingException e) {
            throw new RuntimeException("UTF8 encoding not supported", e);
        }
    }

    private XStream getXStream() {
        XStream xstream = new XStream();
        xstream.setClassLoader(moduleDescriptor.getPlugin().getClassLoader());
        return xstream;
    }
    [...]

Now that we have access to the configuration data, which describes the repositories, we can go ahead and implement our file system based repository class.

The SCMRepository interface offers basic functionality for retrieving file contents of specific file revisions. It is queried by Crucible when a user adds files to a review. Depending on the optional interfaces you implement in addition to SCMRepository, your implementation could also have the ability to browse the repository and to explore different versions of each file. Because a standard file system does not store version information, we'll only offer directory browsing in this example. As a revision key or version number we shall simply use the last modification date that is stored by the file system.

package com.atlassian.scm;

import com.atlassian.crucible.scm.SCMRepository;
import com.atlassian.crucible.scm.RevisionData;
import com.atlassian.crucible.scm.RevisionKey;
import com.atlassian.crucible.scm.DetailConstants;
import com.cenqua.crucible.model.Principal;

import java.io.OutputStream;
import java.io.IOException;
import java.io.File;
import java.io.FileInputStream;
import java.io.InputStream;
import java.util.Date;
import java.net.MalformedURLException;
import java.text.DateFormat;
import java.text.SimpleDateFormat;

import org.apache.commons.io.IOUtils;

public class ExampleSCMRepository implements SCMRepository {

    private final ExampleConfiguration config;

    public ExampleSCMRepository(ExampleConfiguration config) {
        this.config = config;
    }

    public boolean isAvailable(Principal principal) {
        return true;
    }

    public String getName() {
        return config.getName();
    }

    public String getDescription() {
        return getName() + " file system repo at: " + config.getBasePath();
    }

    public String getStateDescription() {
        return "Available";
    }

    public RevisionData getRevisionData(Principal principal,
        RevisionKey revisionKey) {
        if (revisionKey.equals(currentKey(revisionKey.getPath()))) {
            File f = getFile(revisionKey.getPath());

            RevisionData data = new RevisionData();
            data.setDetail(DetailConstants.COMMIT_DATE, new Date(f.lastModified()));
            data.setDetail(DetailConstants.FILE_TYPE, f.isDirectory() ? "dir" : "file");
            data.setDetail(DetailConstants.ADDED, true);
            data.setDetail(DetailConstants.DELETED, false);
            try {
                data.setDetail(DetailConstants.REVISION_LINK, f.toURL().toString());
            } catch (MalformedURLException e) {
            }
            return data;
        } else {
            throw new RuntimeException("Revision " + revisionKey.getRevision() + " of file " + revisionKey.getPath() + " is no longer available.");
        }
    }

    public void streamContents(Principal principal, RevisionKey revisionKey,
        OutputStream outputStream) throws IOException {
        if (revisionKey.equals(currentKey(revisionKey.getPath()))) {
            InputStream is = new FileInputStream(getFile(revisionKey.getPath()));
            try {
                IOUtils.copy(is, outputStream);
            } finally {
                IOUtils.closeQuietly(is);
            }
        } else {
            throw new RuntimeException("Revision " + revisionKey.getRevision() + " of file " + revisionKey.getPath() + " is no longer available.");
        }
    }

    public RevisionKey getDiffRevisionKey(Principal principal,
        RevisionKey revisionKey) {
        // diffs are not supported in this example
        return null;
    }

    /**
     * Returns a {@link RevisionKey} instance for the specified file. Because we
     * do not support versioning, the revision string will be set to the file's
     * last modification date.
     *
     * @param path
     * @return
     */
    private RevisionKey currentKey(String path) {
        File f = getFile(path);
        return new RevisionKey(path, createDateFormat().format(new Date(f.lastModified())));
    }

    /**
     * Takes the name of a file in the repository and returns a file handle to the
     * file on disk.
     *
     * @param path
     * @return
     */
    private File getFile(String path) {
        return new File(config.getBasePath() + File.separator + path);
    }

    private DateFormat createDateFormat() {
        return new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZ");
    }
}

In the above code, the getRevisionData() method is used by Crucible to retrieve versioning properties for a specific revision of a file in the repository. Although the file system does not keep track of older versions, we can provide some of the properties. Most important are the predefined constants DetailConstants.FILE_TYPE, DetailConstants.ADDED, DetailConstants.DELETED (the last two indicate whether the file was newly created (ADDED), or has been removed from the repository (DELETED) as part of the revision) and DetailConstants.REVISION_LINK. In addition to the predefined constants, a repository implementation is free to add custom properties.

We are not able to implement getDiffRevisionKey() due to the lack of version information on the file system.

Before we continue to extend the functionality of the ExampleSCMRepository, we should go back to ExampleSCMModule and implement getRepositories():

[...]

    // initialize at null to trigger loading from the configuration
    private List<SCMRepository> repos = null;

    public synchronized Collection<SCMRepository> getRepositories() {
        if (repos == null) {
            repos = new ArrayList<SCMRepository>();
            for (ExampleConfiguration config : getConfiguration()) {
                repos.add(new ExampleSCMRepository(config));
            }
        }
        return repos;
    }

    public void setConfiguration(List<ExampleConfiguration> config) {
        try {
            store.putConfiguration(moduleDescriptor, xstream.toXML(config).getBytes("UTF8"));
            // we're given a new configuration, so reset our repositories:
            repos = null;
        } catch (UnsupportedEncodingException e) {
            throw new RuntimeException("UTF8 encoding not supported", e);
        }
    }

    [...]

Our SCMModule now properly creates the repository instances according to the configuration.

The above code gives us a very simple Crucible SCM plugin. However you would normally also want to implement the com.atlassian.crucible.scm.DirectoryBrowser and com.atlassian.crucible.scm.HasDirectoryBrowser interfaces. The DirectoryBrowser gives Crucible the ability to let the user interactively browse the repository and select files to review. If you do not provide a DirectoryBrowser, the only way to create a review for files in your repository is when the required files and file revisions are known up front.

In this example, we'll implement DirectoryBrowser:

public class FileSystemSCMRepository implements HasDirectoryBrowser, DirectoryBrowser {

    [...]

    public DirectoryBrowser getDirectoryBrowser() {
        return this;
    }

    public List<FileSummary> listFiles(Principal principal, String path) {
        List<FileSummary> files = new ArrayList<FileSummary>();
        for (String p : list(path, true)) {
            files.add(new FileSummary(currentKey(p)));
        }
        return files;
    }

    public List<DirectorySummary> listDirectories(Principal principal, String path) {
        List<DirectorySummary> files = new ArrayList<DirectorySummary>();
        for (String p : list(path, false)) {
            files.add(new DirectorySummary(p));
        }
        return files;
    }

    public FileHistory getFileHistory(Principal principal, String path, String pegRevision) {
        return new FileHistory(Collections.singletonList(currentKey(path)));
    }

    private List<String> list(String path, boolean returnFiles) {
        File parent = getFile(path);
        List<String> files = new ArrayList<String>();
        if (parent.isDirectory()) {
            File[] children = parent.listFiles();
            // this may be null if we can't read the directory, for instance.
            if (children != null) {
                for (File f : children) {
                    if (f.isFile() && returnFiles || f.isDirectory() && !returnFiles) {
                        files.add(getPath(f));
                    }
                }
            }
        }
        return files;
    }

    /**
     * @return the path for a given File relative to the base configured for this
     *         repository -- the path doesn't include the base component.
     */
    private String getPath(File file) {
        String s = file.getAbsolutePath();
        if (!s.startsWith(config.getBasePath())) {
            throw new RuntimeException("Invalid file with path " + s + " is not under base " + config.getBasePath());
        }
        return s.substring(config.getBasePath().length() + 1);
    }

    [...]

This is as far as we can go with the file system. In most cases you will be integrating version control systems that keep track of all previous revisions of the resources in the repository and you would expose this to Crucible by also implemening HasChangelogBrowser and ChangelogBrowser.

Servlet Based Administration Pane

With the code for the module and the repository in place, we can focus on our servlet that provide plugin administration in Crucible's administration section. The easiest way to do this is to subclass com.atlassian.fisheye.plugins.scm.utils.SimpleConfigurationServlet and implement the three abstract methods:

package com.atlassian.crucible.example.scm;

import com.atlassian.fisheye.plugins.scm.utils.SimpleConfigurationServlet;
import com.atlassian.plugin.PluginAccessor;
import com.atlassian.crucible.spi.FisheyePluginUtilities;

public class ExampleSCMConfigServlet extends SimpleConfigurationServlet<ExampleConfiguration> {

    public ExampleSCMConfigServlet(PluginAccessor pluginAccessor,
        FisheyePluginUtilities fisheyePluginUtilities) {
        super(pluginAccessor, fisheyePluginUtilities);
    }

    protected ExampleConfiguration defaultConfig() {
        return new ExampleConfiguration();
    }

    protected String getProviderPluginModuleKey() {
        return "com.atlassian.crucible.example.scm.example-scm-plugin:scmprovider";
    }

    protected String getTemplatePackage() {
        return "/examplescm-templates";
    }
}

The getTemplatePackage() method returns the name of the resource directory that contains the velocity templates that determine how the configuration pane will be rendered. The template directory must be in src/main/resources so Crucible can find them. We'll create three different pages: one that lists the current configuration list.vm, one to edit a repository's configuration edit.vm and one that is displayed when the user tries to manipulate a non-existing repository instance (nosuchrepo.vm):

src/main/resource/examplescm-templates/list.vm
<html>
<head>
    <link rel="stylesheet" href="$request.contextPath/$STATICDIR/main.css" type="text/css" />
</head>
<body class="plugin">
<div class="box formPane">
<table class="adminTable">
#if ($configs.empty)
        <tr><td>No File System repositories are configured.</td></tr>
#else
    <tr>
        <th>Name</th>
        <th>Base Path</th>
        <th><!-- for edit link --></th>
        <th><!-- for delete link --></th>
    </tr>
    #foreach ($config in $configs)
    <tr>
        <td>$config.name</td>
        <td>$config.basePath</td>
        <td><a href="./examplescm?name=$config.name">Edit</a></td>
        <td><a href="./examplescm?name=$config.name&amp;delete=true">Delete</a></td>
    </tr>
    #end
#end
    <tr>
        <td class="verb"><a href="./examplescm?name=_new">Add a repository.</a></td>
    </tr>
</table>
</div>
</body>
</html>
src/main/resource/examplescm-templates/edit.vm
<html>
<head>
    <link rel="stylesheet" href="$request.contextPath/$STATICDIR/main.css" type="text/css" />
</head>
<body class="plugin">
<div class="box formPane">
<form action="./examplescm" method="POST">
    #if ($config.name)
    <input type="hidden" name="name" value="$!config.name"/>
    #end
    <table class="adminTable">
         #if ($errorMessage)
        <tr><td colspan="2"><span class="errorMessage">$errorMessage</span></td></tr>
        #end
        <tr>
            <td class="tdLabel"><label class="label">Name:</label></td> <td><input
            #if ($config.name)
                disabled="true"
            #else
                name="name"
            #end
            type="text"  value="$!config.name"/> </td>
        </tr>
        <tr>
            <td class="tdLabel"><label class="label">Base Path:</label></td> <td><input type="text" name="basePath" value="$!config.basePath"/> </td>
        </tr>
        <tr>
            <td colspan="2">
                <input type="submit" value="Save"/>
            </td>
        </tr>
    </table>
</form>
</div>
</body>
</html>
src/main/resource/examplescm-templates/nosuchrepo.vm
<html>
<head>
    <link rel="stylesheet" href="$request.contextPath/$STATICDIR/main.css" type="text/css" />
</head>
<body class="plugin">
<p>
There is no repository named '$name'.
</p>
</body>
</html>

Finally we tie everything together in the mandatory atlassian-plugin.xml file that describes the new plugin, contains its name, location of the servlet and the classnames Crucible uses to instantiate the components. Because this is an SCM plugin, we must add the <scm/> element:

src/main/resources/atlassian-plugin.xml
<atlassian-plugin key="${atlassian.plugin.key}" name="example-scm-plugin" plugins-version="2">
    <plugin-info>
        <description>An example SCM provider for the local file system</description>
        <vendor name="Atlassian" url="http://www.atlassian.com"/>
        <version>1.0-SNAPSHOT</version>
        <param name="configure.url">/plugins/servlet/examplescm</param>
    </plugin-info>

    <scm name="Example File System SCM" key="scmprovider" class="com.atlassian.crucible.example.scm.ExampleSCMModule">
        <description>Example SCM implementation for local file system</description>
    </scm>

    <servlet name="Example File System SCM Configuration Servlet" key="configservlet" class="com.atlassian.crucible.example.scm.ExampleSCMConfigServlet" adminLevel="system">
        <description>Allows Configuration of File System example SCM Plugin</description>
        <url-pattern>/examplescm</url-pattern>
    </servlet>
</atlassian-plugin>

Packaging, Deploying and Running

Now we can test the plugin by deploying it into a Crucible instance. With the Atlassian Plugin SDK this is conveniently done with the atlas-run command. This will start the bundled, pre-confgured FishEye/Crucible instance and automatically compile, package and deploy your new plugin:

$ atlas-run Executing: /Users/ervzijst/opt/atlassian-plugin-sdk-3.0-beta7/apache-maven/bin/mvn com.atlassian.maven.plugins:maven-amps-dispatcher-plugin:run
[INFO] Scanning for projects...
[INFO] ------------------------------------------------------------------------
[INFO] Building example-scm-plugin
...
[INFO] Building jar: /Users/ervzijst/workspace/example-scm-plugin/target/example-scm-plugin-1.0-SNAPSHOT.jar
...
INFO  - FishEye/Crucible 2.0.5 (build-429), Built on 2009-09-28
INFO  - FishEye: Developer License registered to Atlassian. ()
INFO  - Periodic polling for software updates is disabled.
INFO  - Starting plugin system...
INFO  - Starting database...
INFO  - Server started on :3990 (http) (control port on 127.0.0.1:39901)
[INFO] fecru started successfully and available at http://localhost:3990/fecru
[INFO] Type CTRL-C to exit

Now visit http://localhost:3990/fecru and use the admin password "password" to go to the admin section and configure a new instance of our new file system SCM plugin.

Click "Configure" to create a file system based repository:

Screenshot: Creating a File-System Based Repository

When the repository is created, navigate to "Repository List". Our custom Crucible SCM Plugin will now show up in the list and is ready to use:

Screenshot: The Custom SCM Plugin in Crucible

When reviewing files from the plugin repository, click on the "Manage Files" tab in a new or existing review and then select the repository from the pull down list and select the files and revisions you want to review:

Screenshot: Selecting Files and Revisions for Review

Was this page helpful?

Have a question about this article?

See questions about this article

Powered by Confluence and Scroll Viewport