View Javadoc
1   /*
2    * Licensed to the Apache Software Foundation (ASF) under one
3    * or more contributor license agreements.  See the NOTICE file
4    * distributed with this work for additional information
5    * regarding copyright ownership.  The ASF licenses this file
6    * to you under the Apache License, Version 2.0 (the
7    * "License"); you may not use this file except in compliance
8    * with the License.  You may obtain a copy of the License at
9    *
10   *   http://www.apache.org/licenses/LICENSE-2.0
11   *
12   * Unless required by applicable law or agreed to in writing,
13   * software distributed under the License is distributed on an
14   * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15   * KIND, either express or implied.  See the License for the
16   * specific language governing permissions and limitations
17   * under the License.
18   */
19  package org.apache.maven.plugins.changes;
20  
21  import javax.inject.Inject;
22  
23  import java.io.File;
24  import java.io.IOException;
25  import java.io.Writer;
26  import java.net.URL;
27  import java.nio.charset.StandardCharsets;
28  import java.nio.file.Files;
29  import java.nio.file.Path;
30  import java.text.SimpleDateFormat;
31  import java.util.Collections;
32  import java.util.Date;
33  import java.util.List;
34  import java.util.Locale;
35  import java.util.Map;
36  import java.util.Map.Entry;
37  import java.util.Properties;
38  import java.util.ResourceBundle;
39  
40  import org.apache.commons.collections4.map.CaseInsensitiveMap;
41  import org.apache.commons.io.input.XmlStreamReader;
42  import org.apache.maven.plugins.annotations.Mojo;
43  import org.apache.maven.plugins.annotations.Parameter;
44  import org.apache.maven.plugins.changes.model.Release;
45  import org.apache.maven.project.MavenProject;
46  import org.apache.maven.reporting.MavenReportException;
47  import org.apache.maven.shared.filtering.MavenFileFilter;
48  import org.apache.maven.shared.filtering.MavenFileFilterRequest;
49  import org.apache.maven.shared.filtering.MavenFilteringException;
50  import org.codehaus.plexus.util.FileUtils;
51  
52  /**
53   * Goal which creates a nicely formatted Changes Report in html format from a changes.xml file.
54   *
55   * @author <a href="mailto:jruiz@exist.com">Johnny R. Ruiz III</a>
56   * @since 2.0
57   */
58  @Mojo(name = "changes", threadSafe = true)
59  public class ChangesReport extends AbstractChangesReport {
60      /**
61       * A flag whether the report should also include changes from child modules. If set to <code>false</code>, only the
62       * changes from current project will be written to the report.
63       *
64       * @since 2.5
65       */
66      @Parameter(defaultValue = "false")
67      private boolean aggregated;
68  
69      /**
70       * A flag whether the report should also include the dates of individual actions. If set to <code>false</code>, only
71       * the dates of releases will be written to the report.
72       *
73       * @since 2.1
74       */
75      @Parameter(property = "changes.addActionDate", defaultValue = "false")
76      private boolean addActionDate;
77  
78      /**
79       * The directory for interpolated changes.xml.
80       *
81       * @since 2.2
82       */
83      @Parameter(defaultValue = "${project.build.directory}/changes", required = true, readonly = true)
84      private File filteredOutputDirectory;
85  
86      /**
87       * applying filtering filtering "a la" resources plugin
88       *
89       * @since 2.2
90       */
91      @Parameter(defaultValue = "false")
92      private boolean filteringChanges;
93  
94      /**
95       * Template strings per system that is used to discover the URL to use to display an issue report. Each key in this
96       * map denotes the (case-insensitive) identifier of the issue tracking system and its value gives the URL template.
97       * <p>
98       * There are 2 template tokens you can use. <code>%URL%</code>: this is computed by getting the
99       * <code>&lt;issueManagement&gt;/&lt;url&gt;</code> value from the POM, and removing the last '/' and everything
100      * that comes after it. <code>%ISSUE%</code>: this is the issue number.
101      * </p>
102      * <p>
103      * <strong>Note:</strong> The deprecated issueLinkTemplate will be used for a system called "default".
104      * </p>
105      * <p>
106      * <strong>Note:</strong> Starting with version 2.4 you usually don't need to specify this, unless you need to link
107      * to an issue management system in your Changes report that isn't supported out of the box. See the
108      * <a href="./usage.html">Usage page</a> for more information.
109      * </p>
110      *
111      * @since 2.1
112      */
113     @Parameter
114     private Map<String, String> issueLinkTemplatePerSystem;
115 
116     /**
117      * Format to use for publishDate. The value will be available with the following expression ${publishDate}
118      *
119      * @see java.text.SimpleDateFormat
120      * @since 2.2
121      */
122     @Parameter(defaultValue = "yyyy-MM-dd")
123     private String publishDateFormat;
124 
125     /**
126      * Locale to use for publishDate when formatting
127      *
128      * @see java.util.Locale
129      * @since 2.2
130      */
131     @Parameter(defaultValue = "en")
132     private String publishDateLocale;
133 
134     /**
135      * @since 2.4
136      */
137     @Parameter(defaultValue = "${project.issueManagement.system}", readonly = true)
138     private String system;
139 
140     /**
141      * The URI of a file containing all the team members. If this is set to the special value "none", no links will be
142      * generated for the team members.
143      *
144      * @since 2.4
145      */
146     @Parameter(defaultValue = "team.html")
147     private String team;
148 
149     @Parameter(defaultValue = "${project.issueManagement.url}", readonly = true)
150     private String url;
151 
152     /**
153      * The type of the feed to generate.
154      * <p>
155      * Supported values are: <code>"rss_0.9", "rss_0.91N" (RSS 0.91 Netscape), "rss_0.91U" (RSS 0.91 Userland),
156      * "rss_0.92", "rss_0.93", "rss_0.94", "rss_1.0", "rss_2.0", "atom_0.3", "atom_1.0"</code>.
157      * </p>
158      * <p>
159      * If not specified, no feed is generated.
160      * </p>
161      *
162      * @since 2.9
163      */
164     @Parameter
165     private String feedType;
166 
167     /**
168      * The path of the <code>changes.xml</code> file that will be converted into an HTML report.
169      */
170     @Parameter(property = "changes.xmlPath", defaultValue = "src/changes/changes.xml")
171     private File xmlPath;
172 
173     private final MavenFileFilter mavenFileFilter;
174 
175     @Inject
176     public ChangesReport(MavenFileFilter mavenFileFilter) {
177         this.mavenFileFilter = mavenFileFilter;
178     }
179 
180     /* --------------------------------------------------------------------- */
181     /* Public methods */
182     /* --------------------------------------------------------------------- */
183 
184     @Override
185     public boolean canGenerateReport() {
186         // Run only at the execution root
187         if (runOnlyAtExecutionRoot && !isThisTheExecutionRoot()) {
188             getLog().info("Skipping the Changes Report in this project because it's not the Execution Root");
189             return false;
190         }
191         return xmlPath.isFile();
192     }
193 
194     @Override
195     public void executeReport(Locale locale) throws MavenReportException {
196         Date now = new Date();
197         SimpleDateFormat simpleDateFormat = new SimpleDateFormat(publishDateFormat, new Locale(publishDateLocale));
198         Properties additionalProperties = new Properties();
199         additionalProperties.put("publishDate", simpleDateFormat.format(now));
200 
201         ChangesXML changesXml = getChangesFromFile(xmlPath, project, additionalProperties);
202         if (changesXml == null) {
203             return;
204         }
205 
206         if (aggregated) {
207             final String basePath = project.getBasedir().getAbsolutePath();
208             final String absolutePath = xmlPath.getAbsolutePath();
209             if (!absolutePath.startsWith(basePath)) {
210                 getLog().warn("xmlPath should be within the project dir for aggregated changes report.");
211                 return;
212             }
213             final String relativePath = absolutePath.substring(basePath.length());
214 
215             List<Release> releaseList = changesXml.getReleaseList();
216             for (MavenProject childProject : project.getCollectedProjects()) {
217                 final File changesFile = new File(childProject.getBasedir(), relativePath);
218                 final ChangesXML childXml = getChangesFromFile(changesFile, childProject, additionalProperties);
219                 if (childXml != null) {
220                     releaseList =
221                             ReleaseUtils.mergeReleases(releaseList, childProject.getName(), childXml.getReleaseList());
222                 }
223             }
224             changesXml.setReleaseList(releaseList);
225         }
226 
227         ChangesReportRenderer report = new ChangesReportRenderer(getSink(), getBundle(locale), changesXml);
228 
229         report.setIssueLinksPerSystem(prepareIssueLinksPerSystem());
230         report.setSystem(system);
231         report.setTeam(team);
232         report.setUrl(url);
233         report.setAddActionDate(addActionDate);
234 
235         if (url == null || url.isEmpty()) {
236             getLog().warn("No issue management URL defined in POM. Links to your issues will not work correctly.");
237         }
238 
239         boolean feedGenerated = false;
240 
241         if (feedType != null && !feedType.isEmpty()) {
242             feedGenerated = generateFeed(changesXml, locale);
243         }
244 
245         report.setLinkToFeed(feedGenerated);
246 
247         report.render();
248 
249         // Copy the images
250         copyStaticResources();
251     }
252 
253     private Map<String, String> prepareIssueLinksPerSystem() {
254         Map<String, String> issueLinkTemplate;
255         // Create a case insensitive version of issueLinkTemplatePerSystem
256         // We need something case insensitive to maintain backward compatibility
257         if (this.issueLinkTemplatePerSystem == null) {
258             issueLinkTemplate = new CaseInsensitiveMap<>();
259         } else {
260             issueLinkTemplate = new CaseInsensitiveMap<>(this.issueLinkTemplatePerSystem);
261         }
262 
263         // Set good default values for issue management systems here
264         issueLinkTemplate.computeIfAbsent(
265                 ChangesReportRenderer.DEFAULT_ISSUE_SYSTEM_KEY, k -> "%URL%/ViewIssue.jspa?key=%ISSUE%");
266         issueLinkTemplate.computeIfAbsent("Bitbucket", k -> "%URL%/issue/%ISSUE%");
267         issueLinkTemplate.computeIfAbsent("Bugzilla", k -> "%URL%/show_bug.cgi?id=%ISSUE%");
268         issueLinkTemplate.computeIfAbsent("GitHub", k -> "%URL%/%ISSUE%");
269         issueLinkTemplate.computeIfAbsent("GoogleCode", k -> "%URL%/detail?id=%ISSUE%");
270         issueLinkTemplate.computeIfAbsent("JIRA", k -> "%URL%/%ISSUE%");
271         issueLinkTemplate.computeIfAbsent("Mantis", k -> "%URL%/view.php?id=%ISSUE%");
272         issueLinkTemplate.computeIfAbsent("MKS", k -> "%URL%/viewissue?selection=%ISSUE%");
273         issueLinkTemplate.computeIfAbsent("Redmine", k -> "%URL%/issues/show/%ISSUE%");
274         issueLinkTemplate.computeIfAbsent("Scarab", k -> "%URL%/issues/id/%ISSUE%");
275         issueLinkTemplate.computeIfAbsent("SourceForge", k -> "http://sourceforge.net/support/tracker.php?aid=%ISSUE%");
276         issueLinkTemplate.computeIfAbsent("SourceForge2", k -> "%URL%/%ISSUE%");
277         issueLinkTemplate.computeIfAbsent("Trac", k -> "%URL%/ticket/%ISSUE%");
278         issueLinkTemplate.computeIfAbsent("Trackplus", k -> "%URL%/printItem.action?key=%ISSUE%");
279         issueLinkTemplate.computeIfAbsent("Tuleap", k -> "%URL%/?aid=%ISSUE%");
280         issueLinkTemplate.computeIfAbsent("YouTrack", k -> "%URL%/issue/%ISSUE%");
281         // @todo Add more issue management systems here
282         // Remember to also add documentation in usage.apt.vm
283 
284         // Show the current issueLinkTemplatePerSystem configuration
285         logIssueLinkTemplatePerSystem(issueLinkTemplate);
286         return issueLinkTemplate;
287     }
288 
289     @Override
290     public String getDescription(Locale locale) {
291         return getBundle(locale).getString("report.issues.description");
292     }
293 
294     @Override
295     public String getName(Locale locale) {
296         return getBundle(locale).getString("report.issues.name");
297     }
298 
299     @Override
300     @Deprecated
301     public String getOutputName() {
302         return "changes";
303     }
304 
305     /* --------------------------------------------------------------------- */
306     /* Private methods */
307     /* --------------------------------------------------------------------- */
308 
309     /**
310      * Parses specified changes.xml file. It also makes filtering if needed. If specified file doesn't exist it will log
311      * warning and return <code>null</code>.
312      *
313      * @param changesXml changes xml file to parse
314      * @param project maven project to parse changes for
315      * @param additionalProperties additional properties used for filtering
316      * @return parsed <code>ChangesXML</code> instance or null if file doesn't exist
317      * @throws MavenReportException if any errors occurs while parsing
318      */
319     private ChangesXML getChangesFromFile(File changesXml, MavenProject project, Properties additionalProperties)
320             throws MavenReportException {
321         if (!changesXml.exists()) {
322             getLog().warn("changes.xml file " + changesXml.getAbsolutePath() + " does not exist.");
323             return null;
324         }
325 
326         if (filteringChanges) {
327             if (!filteredOutputDirectory.exists()) {
328                 filteredOutputDirectory.mkdirs();
329             }
330             try {
331                 // so we get encoding from the file itself
332                 try (XmlStreamReader xmlStreamReader =
333                         XmlStreamReader.builder().setFile(changesXml).get()) {
334                     String encoding = xmlStreamReader.getEncoding();
335                     File resultFile = new File(
336                             filteredOutputDirectory,
337                             project.getGroupId() + "." + project.getArtifactId() + "-changes.xml");
338 
339                     final MavenFileFilterRequest mavenFileFilterRequest = new MavenFileFilterRequest(
340                             changesXml,
341                             resultFile,
342                             true,
343                             project,
344                             Collections.emptyList(),
345                             false,
346                             encoding,
347                             mavenSession,
348                             additionalProperties);
349                     mavenFileFilter.copyFile(mavenFileFilterRequest);
350                     changesXml = resultFile;
351                 }
352             } catch (IOException | MavenFilteringException e) {
353                 throw new MavenReportException("Exception during filtering changes file : " + e.getMessage(), e);
354             }
355         }
356         return new ChangesXML(changesXml, getLog());
357     }
358 
359     private void copyStaticResources() throws MavenReportException {
360         final String pluginResourcesBase = "org/apache/maven/plugins/changes";
361         String[] resourceNames = {
362             "images/add.gif",
363             "images/fix.gif",
364             "images/icon_help_sml.gif",
365             "images/remove.gif",
366             "images/rss.png",
367             "images/update.gif"
368         };
369         try {
370             getLog().debug("Copying static resources.");
371             for (String resourceName : resourceNames) {
372                 URL url = this.getClass().getClassLoader().getResource(pluginResourcesBase + "/" + resourceName);
373                 FileUtils.copyURLToFile(url, new File(getReportOutputDirectory(), resourceName));
374             }
375         } catch (IOException e) {
376             throw new MavenReportException("Unable to copy static resources.");
377         }
378     }
379 
380     private ResourceBundle getBundle(Locale locale) {
381         return ResourceBundle.getBundle(
382                 "changes-report", locale, this.getClass().getClassLoader());
383     }
384 
385     private void logIssueLinkTemplatePerSystem(Map<String, String> issueLinkTemplatePerSystem) {
386         if (getLog().isDebugEnabled()) {
387             for (Entry<String, String> entry : issueLinkTemplatePerSystem.entrySet()) {
388                 getLog().debug("issueLinkTemplatePerSystem[" + entry.getKey() + "] = " + entry.getValue());
389             }
390         }
391     }
392 
393     private boolean generateFeed(final ChangesXML changesXml, final Locale locale) {
394         getLog().debug("Generating " + feedType + " feed.");
395 
396         boolean success = true;
397 
398         final FeedGenerator feed = new FeedGenerator(locale);
399         feed.setLink(project.getUrl() + "/changes-report.html"); // TODO: better way?
400         feed.setTitle(project.getName() + ": " + changesXml.getTitle());
401         feed.setAuthor(changesXml.getAuthor());
402         feed.setDateFormat(new SimpleDateFormat(publishDateFormat, new Locale(publishDateLocale)));
403 
404         Path changes = getReportOutputDirectory().toPath().resolve("changes.rss");
405         try (Writer writer = Files.newBufferedWriter(changes, StandardCharsets.UTF_8)) {
406             feed.export(changesXml.getReleaseList(), feedType, writer);
407         } catch (IOException ex) {
408             success = false;
409             getLog().warn("Failed to create RSS feed: " + ex.getMessage());
410             getLog().debug(ex);
411         }
412 
413         return success;
414     }
415 }