Skip to content
This repository has been archived by the owner on Dec 11, 2024. It is now read-only.

Commit

Permalink
Do not publish directories with no specs (#21)
Browse files Browse the repository at this point in the history
* Do not publish directories with no specs

This commit ensures that any directories without any specs in them are
not published to Confluence.  Prior to this commit these directories
were being published, appearing in Confluence as pages without any
content or subpages. This was distracting when trying to browse the
published specs in Confluence.

One example of this manifesting itself prior to this commit was if the
Gauge [concept files][1] were put into their own concepts directory.
As we only publish specs and not concepts to Confluence, having concepts
in their own directory was leading to empty directory pages being
published prior to this commit.

The functional tests implementation code in this commit was lifted and
shifted with only minor alterations from [the functional tests repo for
core Gauge][2] (just like the existing functional test implementation
code) - no point in reinventing the wheel.

One other noteworthy thing is that the code to delete a page uses the
low level [Go net http client][3], rather than the higher level
[confluence-go-api client][4]. This is because there was a subtle bug
with the confluence-go-api client (it was returning an error even after
a successful delete, despite returning [the correct 204 status
code][5]). It may be worth removing the confluence-go-api client
altogether in a future pull request, as [minimising dependencies is
generally a good thing][6].

[1]: https://docs.gauge.org/writing-specifications.html#concepts
[2]: https://github.com/getgauge/gauge-tests
[3]: https://pkg.go.dev/net/http
[4]: https://github.com/Virtomize/confluence-go-api
[5]: https://developer.atlassian.com/server/confluence/confluence-rest-api-examples/#delete-a-page
[6]: https://endjin.com/blog/2018/09/whose-package-is-it-anyway-why-its-important-to-minimise-dependencies-in-your-solutions

* Remove unused import

* Exclude long urls in comments from go linting

As per:
golangci/golangci-lint#207 (comment)

* Bump plugin minor version
  • Loading branch information
johnboyes authored Jul 13, 2021
1 parent c6bee2d commit 78e8d71
Show file tree
Hide file tree
Showing 12 changed files with 208 additions and 7 deletions.
7 changes: 7 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ issues:
# Default value for this option is true.
exclude-use-default: false


exclude-rules:
# As per https://github.com/golangci/golangci-lint/issues/207#issuecomment-534771981
- linters:
- lll
source: "^// http"

linters:
# enable-all is deprecated, so enable linters individually
enable:
Expand Down
36 changes: 36 additions & 0 deletions functional-tests/specs/do_not_publish_concepts.spec
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Concepts are not published


## Concepts are not published

* Publish specs to Confluence:

|heading |path |concept|
|---------|-----|-------|
|A spec |specs| |
|A concept|specs|yes |

* Published pages are:

|title |parent |
|----------|----------|
|Space Home| |
|specs |Space Home|
|A spec |specs |

## A directory that just contains concepts is not published

* Publish specs to Confluence:

|heading |path |concept|
|-----------------------------|--------------|-------|
|A spec in the specs dir |specs | |
|A concept in the concepts dir|specs/concepts|yes |

* Published pages are:

|title |parent |
|-----------------------|----------|
|Space Home | |
|specs |Space Home|
|A spec in the specs dir|specs |
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ public abstract class GaugeProject {
private static String executableName = "gauge";
private static String gitExecutableName = "git";
private static String specsDirName = "specs";
private static String conceptsDirName = "concepts";
private ArrayList<Concept> concepts = new ArrayList<>();
private File projectDir;
private String language;
private ArrayList<Specification> specifications = new ArrayList<>();
Expand Down Expand Up @@ -70,6 +72,10 @@ public static GaugeProject createProject(String language, String projName) throw
}
}

public void addConcepts(Concept... newConcepts) {
Collections.addAll(concepts, newConcepts);
}

public boolean initialize(boolean remoteTemplate) throws Exception {
executeGaugeCommand(new String[] { "config", "plugin_kill_timeout", "60000" }, null);
if (remoteTemplate && language.equals("js")) {
Expand Down Expand Up @@ -187,6 +193,37 @@ public Scenario findScenario(String scenarioName, List<Scenario> scenarios) {
return null;
}

public Concept createConcept(String conceptsDirName, String name, Table steps) throws Exception {
File conceptsDir = conceptsDir(conceptsDirName);
if (!conceptsDir.exists()) {
conceptsDir.mkdir();
}
File conceptFile = new File(conceptsDir, "concept_" + System.nanoTime() + ".cpt");
if (conceptFile.exists()) {
throw new RuntimeException("Failed to create concept: " + name + "." + conceptFile.getAbsolutePath() + " : File already exists");
}
Concept concept = new Concept(name);
if (steps != null) {
List<String> columnNames = steps.getColumnNames();
for (TableRow row : steps.getTableRows()) {
concept.addItem(row.getCell(columnNames.get(0)), row.getCell("Type"));
if (columnNames.size() == 2) {
implementStep(new StepImpl(row.getCell(columnNames.get(0)), row.getCell(columnNames.get(1)), false, false, "", ""));
}
}
}
concept.saveAs(conceptFile);
concepts.add(concept);
return concept;
}

public File conceptsDir(String conceptsDirName) {
if (StringUtils.isEmpty(conceptsDirName)) {
return new File(projectDir, Util.combinePath(GaugeProject.specsDirName, GaugeProject.conceptsDirName));
}
return new File(projectDir, conceptsDirName);
}

public boolean executeSpecFolder(String specFolder) throws Exception {
return executeGaugeCommand(new String[] { "run", "--simple-console", "--verbose", specFolder }, null);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.thoughtworks.gauge.test.common.builders;

import com.thoughtworks.gauge.Table;
import com.thoughtworks.gauge.test.common.Concept;

import static com.thoughtworks.gauge.test.common.GaugeProject.getCurrentProject;

public class ConceptBuilder {
private String conceptName;
private Table steps;
private String subDirPath;

public ConceptBuilder withName(String conceptName) {
this.conceptName = conceptName;
return this;
}

public ConceptBuilder withSteps(Table steps) {
this.steps = steps;
return this;
}

public ConceptBuilder withSubDirPath(String subDirPath) {
this.subDirPath = subDirPath;
return this;
}

public Concept build() throws Exception {
Concept concept = getCurrentProject().createConcept(subDirPath, conceptName, steps);
getCurrentProject().addConcepts(concept);
return concept;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
import static org.assertj.core.api.Assertions.assertThat;

import java.io.IOException;
import java.time.Instant;
import java.time.LocalTime;
import java.util.concurrent.TimeUnit;
import java.util.UUID;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import com.thoughtworks.gauge.Step;
import com.thoughtworks.gauge.Table;
import com.thoughtworks.gauge.TableRow;
import com.thoughtworks.gauge.test.common.builders.ConceptBuilder;
import com.thoughtworks.gauge.test.common.builders.SpecificationBuilder;

public class Specification {
Expand All @@ -11,7 +12,11 @@ public class Specification {
public void createSpecs(Table specs) throws Exception {
for (int i = 0; i < specs.getTableRows().size(); i++) {
TableRow row = specs.getTableRows().get(i);
createSpec(row.getCell("heading"), row.getCell("path"), "spec" + i);
if (row.getCell("concept").trim().toLowerCase().equals("yes")) {
createConcept(row.getCell("heading"), row.getCell("path"));
} else {
createSpec(row.getCell("heading"), row.getCell("path"), "spec" + i);
}
}
}

Expand All @@ -20,4 +25,8 @@ public void createSpec(String specName, String subFolder, String filename) throw
.withSubDirPath(subFolder).withFilename(filename).buildAndAddToProject(false);
}

public void createConcept(String conceptName, String subFolder) throws Exception {
new ConceptBuilder().withName(conceptName).withSubDirPath(subFolder).build();
}

}
5 changes: 5 additions & 0 deletions internal/confluence/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ func (c *Client) PublishPage(spaceKey, title, body, parentPageID string) (pageID
return responseContent.ID, nil
}

// DeletePage deletes a page from Confluence
func (c *Client) DeletePage(pageID string) (err error) {
return c.httpClient.DeleteContent(pageID)
}

// SpaceHomepage provides the page ID, no. of children and created time for the given Space's homepage.
func (c *Client) SpaceHomepage(spaceKey string) (string, int, string, error) {
path := fmt.Sprintf("space?spaceKey=%s&expand=homepage.children.page,homepage.history", spaceKey)
Expand Down
33 changes: 33 additions & 0 deletions internal/confluence/api/http/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ func (c *Client) PutJSON(path string, requestBody interface{}) error {
return c.httpPut(path, b)
}

// DeleteContent deletes the Confluence content with the given contentID
// See e.g. https://developer.atlassian.com/server/confluence/confluence-rest-api-examples/#delete-a-page
func (c *Client) DeleteContent(contentID string) error {
path := fmt.Sprintf("content/%s", contentID)
return c.httpDelete(path)
}

func (c *Client) basicAuth() string {
auth := c.username + ":" + c.token
return base64.StdEncoding.EncodeToString([]byte(auth))
Expand Down Expand Up @@ -105,3 +112,29 @@ func (c *Client) httpPut(path string, requestBody []byte) error {

return err
}

func (c *Client) httpDelete(path string) error {
url := fmt.Sprintf("%s/%s", c.restEndpoint, path)
req, _ := http.NewRequest("DELETE", url, nil)
req.Header.Add("Authorization", "Basic "+c.basicAuth())
response, err := c.httpClient.Do(req)

if err != nil {
return err
}

if response.Body != nil {
defer response.Body.Close() //nolint: errcheck
}

responseBody, err := io.ReadAll(response.Body)
if err != nil {
return err
}

if response.StatusCode != 204 { //nolint: gomnd
err = fmt.Errorf("HTTP response error: %d %s", response.StatusCode, responseBody)
}

return err
}
2 changes: 1 addition & 1 deletion internal/confluence/homepage.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ func newHomepage(spaceKey string, a api.Client) (homepage, error) {
// for the Confluence instance that specs are being published to. We calculate this on the fly each
// time because it is not easy at all for the user of the plugin to know the time offset for CQL
// queries required by their Confluence instance - see:
// nolint[:lll] https://community.atlassian.com/t5/Confluence-questions/How-do-I-pass-a-UTC-time-as-the-value-of-lastModified-in-a-REST/qaq-p/1557903
// https://community.atlassian.com/t5/Confluence-questions/How-do-I-pass-a-UTC-time-as-the-value-of-lastModified-in-a-REST/qaq-p/1557903
func (h *homepage) cqlTimeOffset() (int, error) {
logger.Debugf(true, "Confluence homepage ID is %s for space %s", h.spaceKey, h.id)
logger.Debugf(true, "Homepage created at: %v (UTC)", h.created)
Expand Down
9 changes: 7 additions & 2 deletions internal/confluence/publisher.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import (
// Publisher publishes Gauge specifications to Confluence.
type Publisher struct {
apiClient api.Client
space space
space space // Represents the Confluence Space that is published to
specs map[string]Spec // keyed by filepath
}

Expand Down Expand Up @@ -77,7 +77,12 @@ func (p *Publisher) printFailureMessage(s interface{}) {
}

func (p *Publisher) publishAllSpecsUnder(baseSpecPath string) (err error) {
return filepath.WalkDir(baseSpecPath, p.publishIfDirOrSpec)
err = filepath.WalkDir(baseSpecPath, p.publishIfDirOrSpec)
if err != nil {
return err
}

return p.space.deleteEmptyDirPages()
}

func (p *Publisher) publishIfDirOrSpec(path string, d fs.DirEntry, err error) error {
Expand Down
39 changes: 38 additions & 1 deletion internal/confluence/space.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ type Value struct {
LastPublished string `json:"lastPublished"`
}

// UpdateLastPublished stores the time of publishing as a Confluence content property,
// updateLastPublished stores the time of publishing as a Confluence content property,
// so that in the next run of the plugin it can check that the Confluence space has not
// been edited manually in the meantime.
//
Expand All @@ -107,3 +107,40 @@ func (s *space) updateLastPublished() error {

return s.apiClient.SetContentProperty(s.homepage.id, time.LastPublishedPropertyKey, value, s.lastPublished.Version+1)
}

// deleteEmptyDirPages deletes any pages that the plugin has published to in this run
// that are empty directories
func (s *space) deleteEmptyDirPages() (err error) {
for key, page := range s.publishedPages {
if s.isEmptyDir(page) {
err = s.apiClient.DeletePage(page.id)
if err != nil {
return err
}

delete(s.publishedPages, key)
}
}

return nil
}

func (s *space) isEmptyDir(p page) bool {
return p.isDir && s.isChildless(p)
}

func (s *space) isChildless(p page) bool {
return len(s.children(p)) == 0
}

func (s *space) children(page page) []string {
var children []string

for _, p := range s.publishedPages {
if page.id == p.parentID {
children = append(children, p.id)
}
}

return children
}
2 changes: 1 addition & 1 deletion plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"id": "confluence",
"version": "0.7.4",
"version": "0.8.0",
"name": "Confluence",
"description": "Publishes Gauge specifications to Confluence",
"install": {
Expand Down

0 comments on commit 78e8d71

Please sign in to comment.