Repository: preslavmihaylov/todocheck
Pull request #149 (2021-06-24 14:42:42)

Functionality to support Azure boards
Add Azure issue tracker in config

Add Azure issue tracker to factory

Add support for Microsoft Azure boards

Remove redundant print; Add comments

Add unit tests for Azure boards

Cleanup; fix indentation

Add Azure boards public and private example yaml definitions for integration tests.

Add example test file for integrations test

Add Azure integration test scenario.

enable CI on push to master

Apply gofmt

Add base 64 encoding to support token auth for Microsoft Azure.

Update integration tests

Add example file in order to have something to assert against.
Split Azure example files in two folders

Emoto13: It seems like you haven't pulled the latest changes into your fork of the repository. I would suggest doing that in order to avoid merge conflicts.
Emoto13: You can disable github actions on your fork, because the test cases will always break, without proper configuration. The CI will run every time you update this pull request so it seems redundant. 

Patch:
diff --git a/config/issuetracker.go b/config/issuetracker.go
index c506b60..335a790 100644
--- a/config/issuetracker.go
+++ b/config/issuetracker.go
@@ -16,6 +16,7 @@ const (
 	IssueTrackerPivotal               = "PIVOTAL_TRACKER"
 	IssueTrackerRedmine               = "REDMINE"
 	IssueTrackerYoutrack              = "YOUTRACK"
+	IssueTrackerAzure                 = "AZURE"
 )
 
 var ValidIssueTrackerAuthTypes = map[IssueTracker][]AuthType{
@@ -25,6 +26,7 @@ var ValidIssueTrackerAuthTypes = map[IssueTracker][]AuthType{
 	IssueTrackerRedmine:  {AuthTypeNone, AuthTypeAPIToken},
 	IssueTrackerJira:     {AuthTypeNone, AuthTypeOffline},
 	IssueTrackerYoutrack: {AuthTypeAPIToken},
+	IssueTrackerAzure:    {AuthTypeNone, AuthTypeAPIToken},
 }
 
 var validIssueTrackers = []IssueTracker{
@@ -34,6 +36,7 @@ var validIssueTrackers = []IssueTracker{
 	IssueTrackerPivotal,
 	IssueTrackerRedmine,
 	IssueTrackerYoutrack,
+	IssueTrackerAzure,
 }
 
 var originPatterns = map[IssueTracker]*regexp.Regexp{
@@ -43,6 +46,7 @@ var originPatterns = map[IssueTracker]*regexp.Regexp{
 	IssueTrackerPivotal:  regexp.MustCompile(`^(https?://)?(www\.)?pivotaltracker\.com/n/projects/[0-9]+`),
 	IssueTrackerRedmine:  regexp.MustCompile(`^(https?://)?[a-zA-Z0-9\-]+(\.[a-zA-Z0-9]+)+(:[0-9]+)?$`),
 	IssueTrackerYoutrack: regexp.MustCompile(`^(https?://)?(www\.)?[0-9A-z-]{2,}\/?.*$`),
+	IssueTrackerAzure:    regexp.MustCompile(`^(https?://)?(www\.)?dev\.azure\.com/([a-zA-Z0-9]+)+\/([a-zA-Z0-9]+)+.*$`),
 }
 
 // IsValid checks if the given issue tracker is among the valid enum values
diff --git a/issuetracker/factory/factory.go b/issuetracker/factory/factory.go
index a327a83..4ec9a9d 100644
--- a/issuetracker/factory/factory.go
+++ b/issuetracker/factory/factory.go
@@ -3,6 +3,8 @@ package factory
 import (
 	"errors"
 
+	"github.com/preslavmihaylov/todocheck/issuetracker/internal/azureboards"
+
 	"github.com/preslavmihaylov/todocheck/config"
 	"github.com/preslavmihaylov/todocheck/issuetracker"
 	"github.com/preslavmihaylov/todocheck/issuetracker/internal/github"
@@ -29,6 +31,8 @@ func NewIssueTrackerFrom(issueTrackerType config.IssueTracker, authCfg *config.A
 		return pivotaltracker.New(origin, authCfg)
 	case config.IssueTrackerYoutrack:
 		return youtrack.New(origin, authCfg)
+	case config.IssueTrackerAzure:
+		return azureboards.NewIssueTrackerAzure(origin, authCfg)
 	}
 
 	return nil, errors.New("unknown issue tracker " + string(issueTrackerType))
diff --git a/issuetracker/internal/azureboards/azureboards.go b/issuetracker/internal/azureboards/azureboards.go
new file mode 100644
index 0000000..eaaaa98
--- /dev/null
+++ b/issuetracker/internal/azureboards/azureboards.go
@@ -0,0 +1,92 @@
+package azureboards
+
+import (
+	"encoding/base64"
+	"fmt"
+	"net/http"
+	"strings"
+
+	"github.com/preslavmihaylov/todocheck/common"
+	"github.com/preslavmihaylov/todocheck/config"
+	"github.com/preslavmihaylov/todocheck/issuetracker"
+)
+
+const supportedAPIVersion = "6.0"
+const tokenAcquisitionURL = "https://bit.ly/3wxUbNF"
+
+func NewIssueTrackerAzure(origin string, authCfg *config.Auth) (*IssueTracker, error) {
+	return &IssueTracker{origin, authCfg}, nil
+}
+
+// IssueTracker implementation for integrating with public & private Azure Boards issue trackers
+type IssueTracker struct {
+	Origin  string
+	AuthCfg *config.Auth
+}
+
+func (it *IssueTracker) TaskModel() issuetracker.Task {
+	return &Task{}
+}
+
+func (it *IssueTracker) IssueURLFor(taskID string) string {
+	return it.issueAPIOrigin() + it.taskURLFrom(taskID)
+}
+
+func (it *IssueTracker) Exists() bool {
+	return true
+}
+
+// InstrumentMiddleware is a hook to instrument any necessary middleware for connecting with the issue tracker
+func (it *IssueTracker) InstrumentMiddleware(r *http.Request) error {
+	if it.AuthCfg == nil || it.AuthCfg.Type == config.AuthTypeNone {
+		return nil
+	} else if it.AuthCfg.Type != config.AuthTypeAPIToken {
+		return fmt.Errorf("unsupported authentication token type for Azure Boards: %s", it.AuthCfg.Type)
+	}
+
+	common.Assert(it.AuthCfg.Token != "", "authentication token is empty")
+
+	// Encode the token with Base64 and append ":". More on: https://bit.ly/35OZ4H8
+	uEnc := base64.URLEncoding.EncodeToString([]byte(":" + it.AuthCfg.Token))
+
+	r.Header.Add("Authorization", "Basic "+uEnc)
+	return nil
+}
+
+// TokenAcquisitionInstructions returns instructions for manually acquiring the authentication token
+// for Azure Boards and the given authentication type
+func (it *IssueTracker) TokenAcquisitionInstructions() string {
+	if it.AuthCfg.Type == config.AuthTypeNone {
+		return ""
+	}
+
+	return fmt.Sprintf("Please create a read-only access token at Microsoft Azure & paste it here. Learn more at %s", tokenAcquisitionURL)
+}
+
+// taskURLFrom taskID returns the url for the target Azure board task ID to fetch
+func (it *IssueTracker) taskURLFrom(taskID string) string {
+	if strings.HasPrefix(taskID, "#") {
+		return taskID[1:]
+	}
+	return fmt.Sprintf("/%s?api-version=%s", taskID, supportedAPIVersion)
+}
+
+// issueAPIOrigin returns the URL for Azure Boards' issue-fetching API
+func (it *IssueTracker) issueAPIOrigin() string {
+	return fmt.Sprintf("%s/_apis/wit/workitems", it.repositoryURL())
+}
+
+func (it *IssueTracker) repositoryURL() string {
+	scheme, owner, repo := it.urlTokensFromOrigin(it.Origin)
+	return fmt.Sprintf("%s//dev.azure.com/%s/%s", scheme, owner, repo)
+
+}
+
+func (it *IssueTracker) urlTokensFromOrigin(origin string) (scheme, owner, repo string) {
+	tokens := common.RemoveEmptyTokens(strings.Split(strings.ToLower(origin), "/"))
+	if !strings.HasPrefix(tokens[0], "http") {
+		tokens = append([]string{"https:"}, tokens...)
+	}
+	scheme, owner, repo = tokens[0], tokens[2], tokens[3]
+	return
+}
diff --git a/issuetracker/internal/azureboards/task.go b/issuetracker/internal/azureboards/task.go
new file mode 100644
index 0000000..b2c436f
--- /dev/null
+++ b/issuetracker/internal/azureboards/task.go
@@ -0,0 +1,23 @@
+package azureboards
+
+import (
+	"github.com/preslavmihaylov/todocheck/issuetracker/taskstatus"
+)
+
+// Task model
+type Task struct {
+	ID     int `json:"id"`
+	Fields struct {
+		State string `json:"System.State"`
+	}
+}
+
+// GetStatus of github task, based on underlying structure
+func (t *Task) GetStatus() taskstatus.TaskStatus {
+	switch t.Fields.State {
+	case "Done":
+		return taskstatus.Closed
+	default:
+		return taskstatus.Open
+	}
+}


Test patch:
diff --git a/issuetracker/internal/azureboards/azureboards_test.go b/issuetracker/internal/azureboards/azureboards_test.go
new file mode 100644
index 0000000..0e8fd77
--- /dev/null
+++ b/issuetracker/internal/azureboards/azureboards_test.go
@@ -0,0 +1,34 @@
+package azureboards
+
+import (
+	"fmt"
+	"testing"
+)
+
+func Test_IssueTracker_IssueURLFor(t *testing.T) {
+
+	var tests = []struct {
+		testName string
+		input    string
+		taskID   string
+		want     string
+	}{
+		{"URL without special symbols", "https://dev.azure.com/todoerruser/todocheck", "1", "https://dev.azure.com/todoerruser/todocheck/_apis/wit/workitems/1?api-version=6.0"},
+		{"URL with special symbols", "https://dev.azure.com/123foo998!/thebigproject123.", "2", "https://dev.azure.com/123foo998!/thebigproject123./_apis/wit/workitems/2?api-version=6.0"},
+		{"URL without https prefix", "dev.azure.com/todoerruser/todocheck", "225", "https://dev.azure.com/todoerruser/todocheck/_apis/wit/workitems/225?api-version=6.0"},
+		{"URL with special symbol in username", "dev.azure.com/foo.bar/quixproject", "123", "https://dev.azure.com/foo.bar/quixproject/_apis/wit/workitems/123?api-version=6.0"},
+	}
+	for _, tt := range tests {
+		var it IssueTracker
+
+		testname := fmt.Sprintf("%q", tt.testName)
+		t.Run(testname, func(t *testing.T) {
+			it.Origin = tt.input
+			res := it.IssueURLFor(tt.taskID)
+			if res != tt.want {
+				t.Errorf("got %s, want %s", res, tt.want)
+			}
+		})
+	}
+
+}
diff --git a/testing/integrations_test.go b/testing/integrations_test.go
index 786db98..4edcfc6 100644
--- a/testing/integrations_test.go
+++ b/testing/integrations_test.go
@@ -174,6 +174,61 @@ func TestPrivateRedmineIntegration(t *testing.T) {
 	}
 }
 
+func TestPublicAzureIntegration(t *testing.T) {
+	err := scenariobuilder.NewScenario().
+		OnlyRunOnCI().
+		WithBinary("../todocheck").
+		WithBasepath("./scenarios/integrations/azureboards_public").
+		WithConfig("./test_configs/integrations/azureboards_public.yaml").
+		ExpectTodoErr(
+			scenariobuilder.NewTodoErr().
+				WithType(errors.TODOErrTypeMalformed).
+				WithLocation("scenarios/integrations/azureboards_public/main.go", 3).
+				ExpectLine("// TODO MALFORMED Issue")).
+		ExpectTodoErr(
+			scenariobuilder.NewTodoErr().
+				WithType(errors.TODOErrTypeIssueClosed).
+				WithLocation("scenarios/integrations/azureboards_public/main.go", 6).
+				ExpectLine("// TODO 3: An issue in CLOSED column")).
+		ExpectTodoErr(
+			scenariobuilder.NewTodoErr().
+				WithType(errors.TODOErrTypeNonExistentIssue).
+				WithLocation("scenarios/integrations/azureboards_public/main.go", 7).
+				ExpectLine("// TODO 12345: A non-existent issue")).
+		Run()
+	if err != nil {
+		t.Errorf("%s", err)
+	}
+}
+
+func TestPrivateAzureIntegration(t *testing.T) {
+	err := scenariobuilder.NewScenario().
+		OnlyRunOnCI().
+		WithAuthTokenFromEnv("TESTS_AZUREBOARDS_PRIVATE_APITOKEN").
+		WithBinary("../todocheck").
+		WithBasepath("./scenarios/integrations/azureboards_private").
+		WithConfig("./test_configs/integrations/azureboards_private.yaml").
+		ExpectTodoErr(
+			scenariobuilder.NewTodoErr().
+				WithType(errors.TODOErrTypeMalformed).
+				WithLocation("scenarios/integrations/azureboards_private/main.go", 3).
+				ExpectLine("// TODO: 1 A malformed issue")).
+		ExpectTodoErr(
+			scenariobuilder.NewTodoErr().
+				WithType(errors.TODOErrTypeIssueClosed).
+				WithLocation("scenarios/integrations/azureboards_private/main.go", 5).
+				ExpectLine("// TODO 9: An issue in CLOSED column")).
+		ExpectTodoErr(
+			scenariobuilder.NewTodoErr().
+				WithType(errors.TODOErrTypeNonExistentIssue).
+				WithLocation("scenarios/integrations/azureboards_private/main.go", 7).
+				ExpectLine("// TODO 999: A non-existent issue")).
+		Run()
+	if err != nil {
+		t.Errorf("%s", err)
+	}
+}
+
 func baseGithubScenario() *scenariobuilder.TodocheckScenario {
 	return scenariobuilder.NewScenario().
 		WithBinary("../todocheck").
diff --git a/testing/scenarios/integrations/azureboards_private/main.go b/testing/scenarios/integrations/azureboards_private/main.go
new file mode 100644
index 0000000..ca10539
--- /dev/null
+++ b/testing/scenarios/integrations/azureboards_private/main.go
@@ -0,0 +1,7 @@
+package main
+
+// TODO: 1 A malformed issue
+// TODO 8: An issue in DOING column
+// TODO 9: An issue in CLOSED column
+// TODO 7: An issue in TODO column
+// TODO 999: A non-existent issue
diff --git a/testing/scenarios/integrations/azureboards_public/main.go b/testing/scenarios/integrations/azureboards_public/main.go
new file mode 100644
index 0000000..0c75015
--- /dev/null
+++ b/testing/scenarios/integrations/azureboards_public/main.go
@@ -0,0 +1,7 @@
+package main
+
+// TODO MALFORMED Issue
+// TODO 6: An issue in TODO column
+// TODO 4: An issue in DOING column
+// TODO 3: An issue in CLOSED column
+// TODO 12345: A non-existent issue
diff --git a/testing/test_configs/integrations/azureboards_private.yaml b/testing/test_configs/integrations/azureboards_private.yaml
new file mode 100644
index 0000000..7dd5f2c
--- /dev/null
+++ b/testing/test_configs/integrations/azureboards_private.yaml
@@ -0,0 +1,4 @@
+origin: https://dev.azure.com/todoerruser/private_todocheck
+issue_tracker: AZURE
+auth:
+  type: apitoken
diff --git a/testing/test_configs/integrations/azureboards_public.yaml b/testing/test_configs/integrations/azureboards_public.yaml
new file mode 100644
index 0000000..d4fe210
--- /dev/null
+++ b/testing/test_configs/integrations/azureboards_public.yaml
@@ -0,0 +1,2 @@
+origin: https://dev.azure.com/todoerruser/todocheck/
+issue_tracker: AZURE