Repository: preslavmihaylov/todocheck
Pull request #174 (2022-02-09 10:00:45)

implement apitoken authentication method for JIRA issue tracker
related to #60

Implemented support for authenticating with Atlassian-hosted JIRA servers using API Token authentication type. 

## other changes
* I changed the JIRA task status derivation to be based on the `status category` versus the `status name`, because the status name is user-defined, but the status category is not.
* I updated the youtrack server used in integration tests because the previous one was not maintained by the contributor who submitted it, hence, youtrack automatically deleted it.
* Added a space after reading the token from cmdline to make the output prettier
* integration tests for jira incloud hosting. Note that the new integration test is currently failing because the env secret in use will get applied after merging the PR to master

preslavmihaylov: Hi @mehdy mind a quick review on this one?
mehdy: Not at all! Of course :)
preslavmihaylov: yeah, the failing test is because the secret for the jria integration is not visible until this has landed in master.

Patch:
diff --git a/README.md b/README.md
index 98a9e3c..bfbc162 100644
--- a/README.md
+++ b/README.md
@@ -160,7 +160,30 @@ The first time you run the application, it will ask for your [personal access to
 After you've specified it, it will store it in the auth tokens cache for subsequent executions. See the [Authentication](#authentication) section for more info.
 
 ## [Jira](https://www.atlassian.com/software/jira)
-To integrate with your organization's Jira, you'll need to specify `JIRA` as your issue tracker, the origin of your jira server instance, along with an [offline token](#api-tokenoffline-token):
+To integrate with your organization's Jira, you'll need to specify `JIRA` as your issue tracker, the origin of your jira server instance.
+
+Along with that, you'll need to setup auth based on your preferred authentication method.
+
+### API Token
+To use JIRA with an API token, you'll need to have an Atlassian user which you can use to [issue an API token](https://support.atlassian.com/atlassian-account/docs/manage-api-tokens-for-your-atlassian-account/).  
+You'll also need to specify your Atlassian username.
+
+Example:
+```
+origin: https://myawesomeorg.atlassian.net
+issue_tracker: JIRA
+auth:
+  type: apitoken
+  options:
+    username: hello@gmail.com
+```
+
+For more info on how authentication works, see the [Authentication](#authentication) section.
+
+### Offline Token
+For more info, see [offline token](#api-tokenoffline-token).
+
+Example:
 ```
 origin: https://myjira.awesomeorg.com
 issue_tracker: JIRA
diff --git a/authmanager/authmanager.go b/authmanager/authmanager.go
index 5a83900..32d5f27 100644
--- a/authmanager/authmanager.go
+++ b/authmanager/authmanager.go
@@ -59,6 +59,7 @@ func acquireToken(authCfg *config.Auth, tokenKey string, instructions string) er
 		return fmt.Errorf("couldn't acquire token: %w", err)
 	}
 
+	fmt.Println()
 	return setAndPersistToken(authCfg, store, tokenKey, strings.TrimSpace(string(tokenBs)))
 }
 
diff --git a/config/auth.go b/config/auth.go
index f4fcb07..608912b 100644
--- a/config/auth.go
+++ b/config/auth.go
@@ -25,15 +25,17 @@ func defaultAuthCfg() *Auth {
 	return &Auth{
 		Type:        AuthTypeNone,
 		TokensCache: DefaultTokensCache(),
+		Options:     map[string]string{},
 	}
 }
 
 // Auth configuration section for specifying issue tracker auth options
 type Auth struct {
-	Type        AuthType `yaml:"type"`
-	OfflineURL  string   `yaml:"offline_url"`
-	TokensCache string   `yaml:"tokens_cache,omitempty"`
-	Token       string   `yaml:"-"`
+	Type        AuthType          `yaml:"type"`
+	OfflineURL  string            `yaml:"offline_url"`
+	TokensCache string            `yaml:"tokens_cache,omitempty"`
+	Token       string            `yaml:"-"`
+	Options     map[string]string `yaml:"options"`
 }
 
 // DefaultTokensCache for storing auth tokens
diff --git a/config/issuetracker.go b/config/issuetracker.go
index 935077b..0f4829d 100644
--- a/config/issuetracker.go
+++ b/config/issuetracker.go
@@ -24,7 +24,7 @@ var ValidIssueTrackerAuthTypes = map[IssueTracker][]AuthType{
 	IssueTrackerGitlab:   {AuthTypeNone, AuthTypeAPIToken},
 	IssueTrackerPivotal:  {AuthTypeNone, AuthTypeAPIToken},
 	IssueTrackerRedmine:  {AuthTypeNone, AuthTypeAPIToken},
-	IssueTrackerJira:     {AuthTypeNone, AuthTypeOffline},
+	IssueTrackerJira:     {AuthTypeNone, AuthTypeOffline, AuthTypeAPIToken},
 	IssueTrackerYoutrack: {AuthTypeAPIToken},
 	IssueTrackerAzure:    {AuthTypeNone, AuthTypeAPIToken},
 }
diff --git a/issuetracker/internal/jira/jira.go b/issuetracker/internal/jira/jira.go
index 4db4fd3..349f6a4 100644
--- a/issuetracker/internal/jira/jira.go
+++ b/issuetracker/internal/jira/jira.go
@@ -1,6 +1,7 @@
 package jira
 
 import (
+	"encoding/base64"
 	"fmt"
 	"net/http"
 
@@ -40,19 +41,36 @@ func (it *IssueTracker) Exists() bool {
 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.AuthTypeOffline {
+	}
+
+	switch it.AuthCfg.Type {
+	case config.AuthTypeOffline:
+		common.Assert(it.AuthCfg.Token != "", "authentication token is empty")
+		r.Header.Add("Authorization", "Bearer "+it.AuthCfg.Token)
+	case config.AuthTypeAPIToken:
+		common.Assert(it.AuthCfg.Token != "", "authentication token is empty")
+		data := []byte(fmt.Sprintf("%s:%s", it.AuthCfg.Options["username"], it.AuthCfg.Token))
+		r.Header.Add("Authorization", "Basic "+base64.StdEncoding.EncodeToString(data))
+	default:
 		return fmt.Errorf("unsupported authentication token type for jira: %s", it.AuthCfg.Type)
 	}
 
-	common.Assert(it.AuthCfg.Token != "", "authentication token is empty")
-	r.Header.Add("Authorization", "Bearer "+it.AuthCfg.Token)
 	return nil
 }
 
 // TokenAcquisitionInstructions returns instructions for manually acquiring the authentication token
 // for jira and the given authentication type
 func (it *IssueTracker) TokenAcquisitionInstructions() string {
-	return fmt.Sprintf("Please go to %s and paste the offline token below.", it.AuthCfg.OfflineURL)
+	switch it.AuthCfg.Type {
+	case config.AuthTypeOffline:
+		return fmt.Sprintf("Please go to %s and paste the offline token below.", it.AuthCfg.OfflineURL)
+	case config.AuthTypeAPIToken:
+		return "Please go to https://id.atlassian.com/manage-profile/security/api-tokens and paste the api token below."
+	default:
+		common.Assert(false, fmt.Sprintf(
+			"token acquisition requested for unsupported authentication type: %s", it.AuthCfg.Type))
+		return ""
+	}
 }
 
 // TaskURLFrom taskID returns the url for the target Jira task ID to fetch
@@ -62,5 +80,5 @@ func (it *IssueTracker) taskURLFrom(taskID string) string {
 
 // IssueAPIOrigin returns the URL for Jira's issue-fetching API
 func (it *IssueTracker) issueAPIOrigin() string {
-	return fmt.Sprintf("%s/rest/api/latest/issue/", it.Origin)
+	return fmt.Sprintf("%s/rest/api/2/issue/", it.Origin)
 }
diff --git a/issuetracker/internal/jira/task.go b/issuetracker/internal/jira/task.go
index c5fa716..038508d 100644
--- a/issuetracker/internal/jira/task.go
+++ b/issuetracker/internal/jira/task.go
@@ -2,27 +2,21 @@ package jira
 
 import "github.com/preslavmihaylov/todocheck/issuetracker/taskstatus"
 
-// Status for Jira tasks
-type Status struct {
-	Name string `json:"name"`
-}
-
-// Fields for Jira tasks
-type Fields struct {
-	Status `json:"status"`
-}
-
 // Task JSON model as returned by the Jira Rest API
 type Task struct {
-	Fields `json:"fields"`
+	Fields struct {
+		Status struct {
+			StatusCategory struct {
+				Name string `json:"name"`
+			} `json:"statusCategory"`
+		} `json:"status"`
+	} `json:"fields"`
 }
 
 // GetStatus of jira task, based on underlying structure
 func (t *Task) GetStatus() (taskstatus.TaskStatus, error) {
-	switch t.Fields.Status.Name {
+	switch t.Fields.Status.StatusCategory.Name {
 	case "Done":
-		fallthrough
-	case "Closed":
 		return taskstatus.Closed, nil
 	default:
 		return taskstatus.Open, nil
diff --git a/validation/validation.go b/validation/validation.go
index 6c2a963..9c5f172 100644
--- a/validation/validation.go
+++ b/validation/validation.go
@@ -1,6 +1,7 @@
 package validation
 
 import (
+	"errors"
 	"fmt"
 	"net/url"
 
@@ -43,6 +44,12 @@ func Validate(cfg *config.Local, tracker issuetracker.IssueTracker) []error {
 				"         Go to https://developer.github.com/v3/#rate-limiting for more information."))
 	}
 
+	if cfg.IssueTracker == config.IssueTrackerJira && cfg.Auth.Type == config.AuthTypeAPIToken {
+		if _, ok := cfg.Auth.Options["username"]; !ok {
+			errs = append(errs, errors.New("api token authentication for JIRA requires username to be set - https://github.com/preslavmihaylov/todocheck#jira"))
+		}
+	}
+
 	return errs
 }
 


Test patch:
diff --git a/testing/integrations_test.go b/testing/integrations_test.go
index 4edcfc6..d91e66e 100644
--- a/testing/integrations_test.go
+++ b/testing/integrations_test.go
@@ -270,7 +270,7 @@ func TestPublicYoutrackIntegration(t *testing.T) {
 			scenariobuilder.NewTodoErr().
 				WithType(errors.TODOErrTypeIssueClosed).
 				WithLocation("scenarios/integrations/youtrack_public_incloud/main.go", 4).
-				ExpectLine("// TODO DEMO-20: A closed issue")).
+				ExpectLine("// TODO DEMO-1: A closed issue")).
 		ExpectTodoErr(
 			scenariobuilder.NewTodoErr().
 				WithType(errors.TODOErrTypeNonExistentIssue).
@@ -281,3 +281,26 @@ func TestPublicYoutrackIntegration(t *testing.T) {
 		t.Errorf("%s", err)
 	}
 }
+
+func TestJIRA_APIToken_Integration(t *testing.T) {
+	err := scenariobuilder.NewScenario().
+		OnlyRunOnCI().
+		WithBinary("../todocheck").
+		WithBasepath("./scenarios/integrations/jira_apitoken").
+		WithAuthTokenFromEnv("TESTS_JIRA_APITOKEN").
+		WithConfig("./test_configs/integrations/jira_apitoken.yaml").
+		ExpectTodoErr(
+			scenariobuilder.NewTodoErr().
+				WithType(errors.TODOErrTypeIssueClosed).
+				WithLocation("scenarios/integrations/jira_apitoken/main.go", 9).
+				ExpectLine("// TODO TT-3: closed issue")).
+		ExpectTodoErr(
+			scenariobuilder.NewTodoErr().
+				WithType(errors.TODOErrTypeNonExistentIssue).
+				WithLocation("scenarios/integrations/jira_apitoken/main.go", 10).
+				ExpectLine("// TODO TT-999: non-existent issue")).
+		Run()
+	if err != nil {
+		t.Errorf("%s", err)
+	}
+}
diff --git a/testing/scenariobuilder/issuetracker/issuetracker.go b/testing/scenariobuilder/issuetracker/issuetracker.go
index a2d79da..e038b0b 100644
--- a/testing/scenariobuilder/issuetracker/issuetracker.go
+++ b/testing/scenariobuilder/issuetracker/issuetracker.go
@@ -19,12 +19,12 @@ type Status string
 
 // Possible issue statuses to specify for your test scenarios
 const (
-	StatusClosed Status = "Closed"
+	StatusClosed Status = "Done"
 	StatusOpen   Status = "Open"
 )
 
 var trackerToIssuePath = map[Type]string{
-	Jira: "/rest/api/latest/issue/",
+	Jira: "/rest/api/2/issue/",
 }
 
 // IssueURLFrom builds the appropriate expected issue url, given the issue tracker type & issue id
@@ -44,7 +44,9 @@ func BuildResponseFor(t Type, issue string, status Status) []byte {
 		return must(json.Marshal(&jira.Task{
 			Fields: jira.Fields{
 				Status: jira.Status{
-					Name: string(status),
+					StatusCategory: jira.StatusCategory{
+						Name: string(status),
+					},
 				},
 			},
 		}))
diff --git a/testing/scenariobuilder/issuetracker/jira/task.go b/testing/scenariobuilder/issuetracker/jira/task.go
index c5fa716..d1dbf9a 100644
--- a/testing/scenariobuilder/issuetracker/jira/task.go
+++ b/testing/scenariobuilder/issuetracker/jira/task.go
@@ -2,24 +2,26 @@ package jira
 
 import "github.com/preslavmihaylov/todocheck/issuetracker/taskstatus"
 
-// Status for Jira tasks
-type Status struct {
+type StatusCategory struct {
 	Name string `json:"name"`
 }
 
-// Fields for Jira tasks
+type Status struct {
+	StatusCategory StatusCategory `json:"statusCategory"`
+}
+
 type Fields struct {
-	Status `json:"status"`
+	Status Status `json:"status"`
 }
 
 // Task JSON model as returned by the Jira Rest API
 type Task struct {
-	Fields `json:"fields"`
+	Fields Fields `json:"fields"`
 }
 
 // GetStatus of jira task, based on underlying structure
 func (t *Task) GetStatus() (taskstatus.TaskStatus, error) {
-	switch t.Fields.Status.Name {
+	switch t.Fields.Status.StatusCategory.Name {
 	case "Done":
 		fallthrough
 	case "Closed":
diff --git a/testing/scenarios/integrations/youtrack_public_incloud/main.go b/testing/scenarios/integrations/youtrack_public_incloud/main.go
index c430f81..df08dab 100644
--- a/testing/scenarios/integrations/youtrack_public_incloud/main.go
+++ b/testing/scenarios/integrations/youtrack_public_incloud/main.go
@@ -1,5 +1,5 @@
 package main
 
-// TODO DEMO-19: Open issue
-// TODO DEMO-20: A closed issue
+// TODO DEMO-4: Open issue
+// TODO DEMO-1: A closed issue
 // TODO DEMO-1234: A non-existent issue
diff --git a/testing/scenarios/jira_apitoken/main.go b/testing/scenarios/jira_apitoken/main.go
new file mode 100644
index 0000000..e9f7c32
--- /dev/null
+++ b/testing/scenarios/jira_apitoken/main.go
@@ -0,0 +1,13 @@
+package main
+
+import "fmt"
+
+// TODO TT-1: valid issue
+// TODO TT-2: valid issue
+// TODO TT-4: valid issue
+
+// TODO TT-3: closed issue
+// TODO TT-999: non-existent issue
+func main() {
+	fmt.Println("vim-go")
+}
diff --git a/testing/test_configs/integrations/jira_apitoken.yaml b/testing/test_configs/integrations/jira_apitoken.yaml
new file mode 100644
index 0000000..518766f
--- /dev/null
+++ b/testing/test_configs/integrations/jira_apitoken.yaml
@@ -0,0 +1,6 @@
+origin: https://todocheck.atlassian.net
+issue_tracker: JIRA
+auth:
+  type: apitoken
+  options:
+    username: todochecktests@gmail.com
diff --git a/testing/test_configs/integrations/youtrack_public_incloud.yaml b/testing/test_configs/integrations/youtrack_public_incloud.yaml
index 67ec6e1..6fd38e7 100644
--- a/testing/test_configs/integrations/youtrack_public_incloud.yaml
+++ b/testing/test_configs/integrations/youtrack_public_incloud.yaml
@@ -1,4 +1,4 @@
-origin: emcho-spassov.myjetbrains.com
+origin: pmihaylov.myjetbrains.com
 issue_tracker: YOUTRACK
 auth:
-  type: apitoken
\ No newline at end of file
+  type: apitoken