From 994732d5301692488496f1563028f9bb855a8b09 Mon Sep 17 00:00:00 2001 From: kshitij katiyar Date: Fri, 15 Nov 2024 15:47:55 +0530 Subject: [PATCH 01/10] [MM-1137]: Added autolink support for cloud-oauth --- plugin.json | 11 +++++- server/issue.go | 48 ++++++++++++++++++++++++- server/plugin.go | 92 ++++++++++++++++++++++++++++++++++-------------- 3 files changed, 123 insertions(+), 28 deletions(-) diff --git a/plugin.json b/plugin.json index 7488278fe..911f9597d 100644 --- a/plugin.json +++ b/plugin.json @@ -119,7 +119,16 @@ "key": "AdminAPIToken", "display_name": "Admin API Token", "type": "text", - "help_text": "Set this [API token](https://support.atlassian.com/atlassian-account/docs/manage-api-tokens-for-your-atlassian-account/) to get notified for comment and issue created events even if the user triggering the event is not connected to Jira.\n **Note:** API token should be created using an admin Jira account. Otherwise, the notification will not be delivered for the project the user cannot access.", + "help_text": "Set this [API token](https://support.atlassian.com/atlassian-account/docs/manage-api-tokens-for-your-atlassian-account/) to get notified for comment and issue created events even if the user triggering the event is not connected to Jira and setup autolink.\n **Note:** API token should be created using an admin Jira account. Otherwise, the notification will not be delivered for the project the user cannot access.", + "placeholder": "", + "secret": true, + "default": "" + }, + { + "key": "AdminEmail", + "display_name": "Admin Email", + "type": "text", + "help_text": "**Note** Admin email is necessary to setup autolink for Jira plugin", "placeholder": "", "default": "" } diff --git a/server/issue.go b/server/issue.go index f498d5bea..c008035e3 100644 --- a/server/issue.go +++ b/server/issue.go @@ -4,6 +4,7 @@ package main import ( + "encoding/base64" "encoding/json" "fmt" "io" @@ -1103,7 +1104,9 @@ func (p *Plugin) GetIssueDataWithAPIToken(issueID, instanceID string) (*jira.Iss return nil, errors.Wrapf(err, "failed to create http request for fetching issue data. IssueID: %s", issueID) } - req.Header.Set("Authorization", fmt.Sprintf("Basic %s", p.getConfig().AdminAPIToken)) + encodedAuth := base64.StdEncoding.EncodeToString([]byte(p.getConfig().AdminEmail + ":" + p.getConfig().AdminAPIToken)) + req.Header.Set("Authorization", "Basic "+encodedAuth) + req.Header.Set("Accept", "application/json") resp, err := client.Do(req) if err != nil { @@ -1134,3 +1137,46 @@ func (p *Plugin) GetIssueDataWithAPIToken(issueID, instanceID string) (*jira.Iss return issue, nil } + +type ProjectSearchResponse struct { + Self string `json:"self"` + MaxResults int `json:"maxResults"` + StartAt int `json:"startAt"` + Total int `json:"total"` + IsLast bool `json:"isLast"` + Values jira.ProjectList `json:"values"` +} + +func (p *Plugin) GetProjectListWithAPIToken(instanceID string) (*jira.ProjectList, error) { + client := &http.Client{} + req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/rest/api/3/project/search", instanceID), nil) + if err != nil { + return nil, errors.Wrapf(err, "failed to create HTTP request for fetching project list data. InstanceID: %s", instanceID) + } + + encodedAuth := base64.StdEncoding.EncodeToString([]byte(p.getConfig().AdminEmail + ":" + p.getConfig().AdminAPIToken)) + req.Header.Set("Authorization", "Basic "+encodedAuth) + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + return nil, errors.Wrapf(err, "failed to fetch project list data. InstanceID: %s", instanceID) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, errors.Errorf("unexpected status code: %d. InstanceID: %s", resp.StatusCode, instanceID) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, errors.Wrap(err, "failed to read response body") + } + + var projectResponse ProjectSearchResponse + if err = json.Unmarshal(body, &projectResponse); err != nil { + return nil, errors.Wrap(err, "failed to unmarshal project list response") + } + + return &projectResponse.Values, nil +} diff --git a/server/plugin.go b/server/plugin.go index 02b5c34c5..b4586f15e 100644 --- a/server/plugin.go +++ b/server/plugin.go @@ -18,6 +18,7 @@ import ( "sync" textTemplate "text/template" + "github.com/andygrunwald/go-jira" "github.com/gorilla/mux" "github.com/pkg/errors" @@ -84,6 +85,9 @@ type externalConfig struct { // API token from Jira AdminAPIToken string + + // Email of the admin + AdminEmail string } const defaultMaxAttachmentSize = utils.ByteSize(10 * 1024 * 1024) // 10Mb @@ -309,39 +313,61 @@ func (p *Plugin) OnActivate() error { p.enterpriseChecker = enterprise.NewEnterpriseChecker(p.API) go func() { - for _, url := range instances.IDs() { - var instance Instance - instance, err = p.instanceStore.LoadInstance(url) - if err != nil { - continue - } + p.SetupAutolink(instances) + }() - ci, ok := instance.(*cloudInstance) - if !ok { - p.client.Log.Info("only cloud instances supported for autolink", "err", err) - continue - } - var status *model.PluginStatus - status, err = p.client.Plugin.GetPluginStatus(autolinkPluginID) - if err != nil { - p.client.Log.Warn("OnActivate: Autolink plugin unavailable. API returned error", "error", err.Error()) - continue - } - if status.State != model.PluginStateRunning { - p.client.Log.Warn("OnActivate: Autolink plugin unavailable. Plugin is not running", "status", status) - continue - } + p.initializeTelemetry() + + return nil +} + +func (p *Plugin) SetupAutolink(instances *Instances) { + for _, url := range instances.IDs() { + var instance Instance + instance, err := p.instanceStore.LoadInstance(url) + if err != nil { + continue + } + + if p.getConfig().AdminAPIToken == "" || p.getConfig().AdminEmail == "" { + p.client.Log.Info("unable to setup autolink due to missing API Token or Admin Email") + continue + } + + ci, ciOk := instance.(*cloudInstance) + coi, coiOk := instance.(*cloudOAuthInstance) + + if !ciOk && !coiOk { + p.client.Log.Info("only cloud and cloud-oauth instances supported for autolink", "err", err) + continue + } + + var status *model.PluginStatus + status, err = p.client.Plugin.GetPluginStatus(autolinkPluginID) + if err != nil { + p.client.Log.Warn("OnActivate: Autolink plugin unavailable. API returned error", "error", err.Error()) + continue + } + + if status.State != model.PluginStateRunning { + p.client.Log.Warn("OnActivate: Autolink plugin unavailable. Plugin is not running", "status", status) + continue + } + if ciOk { if err = p.AddAutolinksForCloudInstance(ci); err != nil { p.client.Log.Info("could not install autolinks for cloud instance", "instance", ci.BaseURL, "err", err) continue } } - }() - - p.initializeTelemetry() - return nil + if coiOk { + if err = p.AddAutolinksForCloudOAuthInstance(coi); err != nil { + p.client.Log.Info("could not install autolinks for cloud-oauth instance", "instance", coi.JiraBaseURL, "err", err) + continue + } + } + } } func (p *Plugin) AddAutolinksForCloudInstance(ci *cloudInstance) error { @@ -355,9 +381,23 @@ func (p *Plugin) AddAutolinksForCloudInstance(ci *cloudInstance) error { return fmt.Errorf("unable to get project keys: %w", err) } + return p.AddAutoLinkForProjects(plist, ci.BaseURL) +} + +func (p *Plugin) AddAutolinksForCloudOAuthInstance(coi *cloudOAuthInstance) error { + plist, err := p.GetProjectListWithAPIToken(string(coi.InstanceID)) + if err != nil { + return fmt.Errorf("error getting project list: %w", err) + } + + return p.AddAutoLinkForProjects(*plist, coi.JiraBaseURL) +} + +func (p *Plugin) AddAutoLinkForProjects(plist jira.ProjectList, baseURL string) error { + var err error for _, proj := range plist { key := proj.Key - err = p.AddAutolinks(key, ci.BaseURL) + err = p.AddAutolinks(key, baseURL) } if err != nil { return fmt.Errorf("some keys were not installed: %w", err) From 6f11bf7eb7e9ab2ec477182387f8c61e6cef4476 Mon Sep 17 00:00:00 2001 From: kshitij katiyar Date: Fri, 15 Nov 2024 17:47:53 +0530 Subject: [PATCH 02/10] [MM-1137]: Adding encryption key field in plugin config and encrypting admin api token --- plugin.json | 7 +++++++ server/issue.go | 26 ++++++++++++++++++++++++-- server/plugin.go | 16 ++++++++++++++++ 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/plugin.json b/plugin.json index 911f9597d..c8b0a585f 100644 --- a/plugin.json +++ b/plugin.json @@ -115,6 +115,13 @@ "placeholder": "", "default": false }, + { + "key": "EncryptionKey", + "display_name": "At Rest Encryption Key:", + "type": "generated", + "help_text": "The AES encryption key used to encrypt stored api tokens.", + "secret": true + }, { "key": "AdminAPIToken", "display_name": "Admin API Token", diff --git a/server/issue.go b/server/issue.go index c008035e3..75480cc82 100644 --- a/server/issue.go +++ b/server/issue.go @@ -1104,7 +1104,18 @@ func (p *Plugin) GetIssueDataWithAPIToken(issueID, instanceID string) (*jira.Iss return nil, errors.Wrapf(err, "failed to create http request for fetching issue data. IssueID: %s", issueID) } - encodedAuth := base64.StdEncoding.EncodeToString([]byte(p.getConfig().AdminEmail + ":" + p.getConfig().AdminAPIToken)) + encryptedAdminAPIToken := p.getConfig().AdminAPIToken + jsonBytes, err := decrypt([]byte(encryptedAdminAPIToken), []byte(p.getConfig().EncryptionKey)) + if err != nil { + return nil, err + } + var adminAPIToken string + err = json.Unmarshal(jsonBytes, &adminAPIToken) + if err != nil { + return nil, err + } + + encodedAuth := base64.StdEncoding.EncodeToString([]byte(p.getConfig().AdminEmail + ":" + adminAPIToken)) req.Header.Set("Authorization", "Basic "+encodedAuth) req.Header.Set("Accept", "application/json") @@ -1154,7 +1165,18 @@ func (p *Plugin) GetProjectListWithAPIToken(instanceID string) (*jira.ProjectLis return nil, errors.Wrapf(err, "failed to create HTTP request for fetching project list data. InstanceID: %s", instanceID) } - encodedAuth := base64.StdEncoding.EncodeToString([]byte(p.getConfig().AdminEmail + ":" + p.getConfig().AdminAPIToken)) + encryptedAdminAPIToken := p.getConfig().AdminAPIToken + jsonBytes, err := decrypt([]byte(encryptedAdminAPIToken), []byte(p.getConfig().EncryptionKey)) + if err != nil { + return nil, err + } + var adminAPIToken string + err = json.Unmarshal(jsonBytes, &adminAPIToken) + if err != nil { + return nil, err + } + + encodedAuth := base64.StdEncoding.EncodeToString([]byte(p.getConfig().AdminEmail + ":" + adminAPIToken)) req.Header.Set("Authorization", "Basic "+encodedAuth) req.Header.Set("Accept", "application/json") diff --git a/server/plugin.go b/server/plugin.go index b4586f15e..c6ce5b365 100644 --- a/server/plugin.go +++ b/server/plugin.go @@ -46,6 +46,8 @@ const ( WebhookMaxProcsPerServer = 20 WebhookBufferSize = 10000 PluginRepo = "https://github.com/mattermost/mattermost-plugin-jira" + + apiTokenEncryptionKey = "token_encryption_key" ) type externalConfig struct { @@ -83,6 +85,9 @@ type externalConfig struct { // Display subscription name in notifications DisplaySubscriptionNameInNotifications bool + // The AES encryption key used to encrypt stored api tokens + EncryptionKey string + // API token from Jira AdminAPIToken string @@ -183,6 +188,17 @@ func (p *Plugin) OnConfigurationChange() error { } } + adminAPIToken := ec.AdminAPIToken + jsonBytes, err := json.Marshal(adminAPIToken) + if err != nil { + return err + } + encryptedAdminAPIToken, err := encrypt(jsonBytes, []byte(p.getConfig().EncryptionKey)) + if err != nil { + return err + } + ec.AdminAPIToken = string(encryptedAdminAPIToken) + prev := p.getConfig() p.updateConfig(func(conf *config) { conf.externalConfig = ec From 796e0c194822341a99363445e561fe9b66c8c0b0 Mon Sep 17 00:00:00 2001 From: kshitij katiyar Date: Fri, 15 Nov 2024 19:56:27 +0530 Subject: [PATCH 03/10] [MM-1137]: Added testcases for setupAutolink function --- server/kv_mock_test.go | 22 ++++--- server/plugin.go | 4 +- server/plugin_test.go | 136 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 150 insertions(+), 12 deletions(-) diff --git a/server/kv_mock_test.go b/server/kv_mock_test.go index b5b90e016..fe750bbcd 100644 --- a/server/kv_mock_test.go +++ b/server/kv_mock_test.go @@ -9,6 +9,7 @@ import ( jira "github.com/andygrunwald/go-jira" "github.com/pkg/errors" + "github.com/stretchr/testify/mock" "github.com/mattermost/mattermost-plugin-jira/server/utils/types" ) @@ -98,26 +99,29 @@ func (store mockUserStore) MapUsers(func(*User) error) error { return nil } -type mockInstanceStore struct{} +type mockInstanceStore struct { + mock.Mock +} -func (store mockInstanceStore) CreateInactiveCloudInstance(types.ID, string) error { +func (store *mockInstanceStore) CreateInactiveCloudInstance(types.ID, string) error { return nil } -func (store mockInstanceStore) DeleteInstance(types.ID) error { +func (store *mockInstanceStore) DeleteInstance(types.ID) error { return nil } -func (store mockInstanceStore) LoadInstance(types.ID) (Instance, error) { - return &testInstance{}, nil +func (store *mockInstanceStore) LoadInstance(id types.ID) (Instance, error) { + args := store.Called(id) + return args.Get(0).(Instance), args.Error(1) } -func (store mockInstanceStore) LoadInstanceFullKey(string) (Instance, error) { +func (store *mockInstanceStore) LoadInstanceFullKey(string) (Instance, error) { return &testInstance{}, nil } -func (store mockInstanceStore) LoadInstances() (*Instances, error) { +func (store *mockInstanceStore) LoadInstances() (*Instances, error) { return NewInstances(), nil } -func (store mockInstanceStore) StoreInstance(instance Instance) error { +func (store *mockInstanceStore) StoreInstance(instance Instance) error { return nil } -func (store mockInstanceStore) StoreInstances(*Instances) error { +func (store *mockInstanceStore) StoreInstances(*Instances) error { return nil } diff --git a/server/plugin.go b/server/plugin.go index c6ce5b365..0558c5307 100644 --- a/server/plugin.go +++ b/server/plugin.go @@ -46,8 +46,6 @@ const ( WebhookMaxProcsPerServer = 20 WebhookBufferSize = 10000 PluginRepo = "https://github.com/mattermost/mattermost-plugin-jira" - - apiTokenEncryptionKey = "token_encryption_key" ) type externalConfig struct { @@ -354,7 +352,7 @@ func (p *Plugin) SetupAutolink(instances *Instances) { coi, coiOk := instance.(*cloudOAuthInstance) if !ciOk && !coiOk { - p.client.Log.Info("only cloud and cloud-oauth instances supported for autolink", "err", err) + p.client.Log.Info("only cloud and cloud-oauth instances supported for autolink") continue } diff --git a/server/plugin_test.go b/server/plugin_test.go index b268a213b..137e8cc7f 100644 --- a/server/plugin_test.go +++ b/server/plugin_test.go @@ -19,6 +19,16 @@ import ( "github.com/stretchr/testify/assert" ) +const ( + MockInstanceID = "mockInstanceID" + MockAPIToken = "mockAPIToken" + MockAdminEmail = "mockadmin@email.com" + MockBaseURL = "mockBaseURL" + MockASCKey = "mockAtlassianSecurityContextKey" + MockASCClientKey = "mockAtlassianSecurityContextClientKey" + MockASCSharedSecret = "mockAtlassianSecurityContextSharedSecret" // #nosec G101: Potential hardcoded credentials - This is a mock for testing purposes +) + func validRequestBody() io.ReadCloser { if f, err := os.Open("testdata/webhook-issue-created.json"); err != nil { panic(err) @@ -144,3 +154,129 @@ func TestPlugin(t *testing.T) { }) } } + +func TestSetupAutolink(t *testing.T) { + mockAPI := &plugintest.API{} + dummyInstanceStore := new(mockInstanceStore) + mockPluginClient := pluginapi.NewClient(mockAPI, nil) + p := &Plugin{ + client: mockPluginClient, + instanceStore: dummyInstanceStore, + } + + tests := []struct { + name string + setup func() + InstanceType InstanceType + }{ + { + name: "Missing API token or Admin email", + setup: func() { + mockAPI.On("LogInfo", "unable to setup autolink due to missing API Token or Admin Email").Return(nil).Times(1) + dummyInstanceStore.On("LoadInstance", mock.Anything).Return(&serverInstance{}, nil).Times(1) + + p.updateConfig(func(c *config) { + c.AdminAPIToken = "" + c.AdminEmail = "" + }) + }, + InstanceType: ServerInstanceType, + }, + { + name: "Unsupported instance type", + setup: func() { + mockAPI.On("LogInfo", "only cloud and cloud-oauth instances supported for autolink").Return(nil).Times(1) + dummyInstanceStore.On("LoadInstance", mock.Anything).Return(&serverInstance{}, nil).Times(1) + + p.updateConfig(GetConfigSetterFunction()) + }, + InstanceType: ServerInstanceType, + }, + { + name: "Autolink plugin unavailable API returned error", + setup: func() { + mockAPI.On("LogWarn", "OnActivate: Autolink plugin unavailable. API returned error", "error", mock.Anything).Return(nil).Times(1) + mockAPI.On("GetPluginStatus", autolinkPluginID).Return(nil, &model.AppError{Message: "error getting plugin status"}).Times(1) + dummyInstanceStore.On("LoadInstance", mock.Anything).Return(&cloudInstance{}, nil).Times(1) + + p.updateConfig(GetConfigSetterFunction()) + }, + InstanceType: CloudInstanceType, + }, + { + name: "Autolink plugin not running", + setup: func() { + mockAPI.On("LogWarn", "OnActivate: Autolink plugin unavailable. Plugin is not running", "status", &model.PluginStatus{State: model.PluginStateNotRunning}).Return(nil).Times(1) + mockAPI.On("GetPluginStatus", autolinkPluginID).Return(&model.PluginStatus{State: model.PluginStateNotRunning}, nil).Times(1) + dummyInstanceStore.On("LoadInstance", mock.Anything).Return(&cloudInstance{}, nil).Times(1) + + p.updateConfig(GetConfigSetterFunction()) + }, + InstanceType: CloudInstanceType, + }, + { + name: "Error installing autolinks for cloud instance", + setup: func() { + mockAPI.On("LogInfo", "could not install autolinks for cloud instance", "instance", "mockBaseURL", "err", mock.Anything).Return(nil).Times(1) + mockAPI.On("GetPluginStatus", autolinkPluginID).Return(&model.PluginStatus{State: model.PluginStateRunning}, nil).Times(1) + dummyInstanceStore.On("LoadInstance", mock.Anything).Return( + &cloudInstance{ + InstanceCommon: &InstanceCommon{ + Plugin: p, + }, + AtlassianSecurityContext: &AtlassianSecurityContext{ + BaseURL: MockBaseURL, + Key: MockASCKey, + ClientKey: MockASCClientKey, + SharedSecret: MockASCSharedSecret, + }, + }, nil).Times(1) + + p.updateConfig(GetConfigSetterFunction()) + }, + InstanceType: CloudInstanceType, + }, + { + name: "Error installing autolinks for cloud-oauth instance", + setup: func() { + mockAPI.On("LogInfo", "could not install autolinks for cloud-oauth instance", "instance", "mockBaseURL", "err", mock.Anything).Return(nil).Times(1) + mockAPI.On("GetPluginStatus", autolinkPluginID).Return(&model.PluginStatus{State: model.PluginStateRunning}, nil).Times(1) + dummyInstanceStore.On("LoadInstance", mock.Anything).Return( + &cloudOAuthInstance{ + InstanceCommon: &InstanceCommon{ + Plugin: p, + }, + JiraBaseURL: MockBaseURL, + }, nil).Times(1) + + p.updateConfig(GetConfigSetterFunction()) + }, + InstanceType: CloudOAuthInstanceType, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.setup() + instances := GetInstancesWithType(tt.InstanceType) + + p.SetupAutolink(instances) + + mockAPI.AssertExpectations(t) + dummyInstanceStore.AssertExpectations(t) + }) + } +} + +func GetConfigSetterFunction() func(*config) { + return func(c *config) { + c.AdminAPIToken = MockAPIToken + c.AdminEmail = MockAdminEmail + } +} + +func GetInstancesWithType(instanceType InstanceType) *Instances { + return NewInstances(&InstanceCommon{ + InstanceID: MockInstanceID, + Type: instanceType, + }) +} From 5aa82d4909ea82d4333083a0fcf618f5936dc84c Mon Sep 17 00:00:00 2001 From: kshitij katiyar Date: Mon, 18 Nov 2024 15:33:53 +0530 Subject: [PATCH 04/10] [MM-1137]: Updated AdminAPIToken help text --- plugin.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin.json b/plugin.json index c8b0a585f..74be323b9 100644 --- a/plugin.json +++ b/plugin.json @@ -126,7 +126,7 @@ "key": "AdminAPIToken", "display_name": "Admin API Token", "type": "text", - "help_text": "Set this [API token](https://support.atlassian.com/atlassian-account/docs/manage-api-tokens-for-your-atlassian-account/) to get notified for comment and issue created events even if the user triggering the event is not connected to Jira and setup autolink.\n **Note:** API token should be created using an admin Jira account. Otherwise, the notification will not be delivered for the project the user cannot access.", + "help_text": "Set this [API token](https://support.atlassian.com/atlassian-account/docs/manage-api-tokens-for-your-atlassian-account/) to get notified for comment and issue created events even if the user triggering the event is not connected to Jira and setup autolink.\n **Note:** API token should be created using an admin Jira account. Otherwise, the notification will not be delivered for the project the user cannot access and autolink will not work.", "placeholder": "", "secret": true, "default": "" From 5df09f5773800707909beb5de49f38c12107feb3 Mon Sep 17 00:00:00 2001 From: kshitij katiyar Date: Thu, 5 Dec 2024 14:06:04 +0530 Subject: [PATCH 05/10] [MM-1137]: Review fixes --- plugin.json | 6 +++--- server/issue.go | 25 ++----------------------- server/plugin.go | 40 ++++++++++++++++++---------------------- server/plugin_test.go | 39 +++++++++++++++++++++------------------ server/utils.go | 23 +++++++++++++++++++++++ 5 files changed, 67 insertions(+), 66 deletions(-) diff --git a/plugin.json b/plugin.json index 74be323b9..79956ce2c 100644 --- a/plugin.json +++ b/plugin.json @@ -119,14 +119,14 @@ "key": "EncryptionKey", "display_name": "At Rest Encryption Key:", "type": "generated", - "help_text": "The AES encryption key used to encrypt stored api tokens.", + "help_text": "The encryption key used to encrypt stored api tokens.", "secret": true }, { "key": "AdminAPIToken", "display_name": "Admin API Token", "type": "text", - "help_text": "Set this [API token](https://support.atlassian.com/atlassian-account/docs/manage-api-tokens-for-your-atlassian-account/) to get notified for comment and issue created events even if the user triggering the event is not connected to Jira and setup autolink.\n **Note:** API token should be created using an admin Jira account. Otherwise, the notification will not be delivered for the project the user cannot access and autolink will not work.", + "help_text": "Set this [API token](https://support.atlassian.com/atlassian-account/docs/manage-api-tokens-for-your-atlassian-account/) to get notified for comment and issue created events even if the user triggering the event is not connected to Jira. This is also used for setting up autolink in the plugin.\n **Note:** API token should be created using an admin Jira account. Otherwise, the notification will not be delivered for the project the user cannot access and autolink will not work.", "placeholder": "", "secret": true, "default": "" @@ -135,7 +135,7 @@ "key": "AdminEmail", "display_name": "Admin Email", "type": "text", - "help_text": "**Note** Admin email is necessary to setup autolink for Jira plugin", + "help_text": "**Note** Admin email is necessary to setup autolink for Jira plugin and to to get notified for comment and issue created events even if the user triggering the event is not connected to Jira", "placeholder": "", "default": "" } diff --git a/server/issue.go b/server/issue.go index 75480cc82..bdcc71e64 100644 --- a/server/issue.go +++ b/server/issue.go @@ -4,7 +4,6 @@ package main import ( - "encoding/base64" "encoding/json" "fmt" "io" @@ -1104,20 +1103,10 @@ func (p *Plugin) GetIssueDataWithAPIToken(issueID, instanceID string) (*jira.Iss return nil, errors.Wrapf(err, "failed to create http request for fetching issue data. IssueID: %s", issueID) } - encryptedAdminAPIToken := p.getConfig().AdminAPIToken - jsonBytes, err := decrypt([]byte(encryptedAdminAPIToken), []byte(p.getConfig().EncryptionKey)) + err = p.SetAdminAPITokenRequestHeader(req) if err != nil { return nil, err } - var adminAPIToken string - err = json.Unmarshal(jsonBytes, &adminAPIToken) - if err != nil { - return nil, err - } - - encodedAuth := base64.StdEncoding.EncodeToString([]byte(p.getConfig().AdminEmail + ":" + adminAPIToken)) - req.Header.Set("Authorization", "Basic "+encodedAuth) - req.Header.Set("Accept", "application/json") resp, err := client.Do(req) if err != nil { @@ -1165,20 +1154,10 @@ func (p *Plugin) GetProjectListWithAPIToken(instanceID string) (*jira.ProjectLis return nil, errors.Wrapf(err, "failed to create HTTP request for fetching project list data. InstanceID: %s", instanceID) } - encryptedAdminAPIToken := p.getConfig().AdminAPIToken - jsonBytes, err := decrypt([]byte(encryptedAdminAPIToken), []byte(p.getConfig().EncryptionKey)) + err = p.SetAdminAPITokenRequestHeader(req) if err != nil { return nil, err } - var adminAPIToken string - err = json.Unmarshal(jsonBytes, &adminAPIToken) - if err != nil { - return nil, err - } - - encodedAuth := base64.StdEncoding.EncodeToString([]byte(p.getConfig().AdminEmail + ":" + adminAPIToken)) - req.Header.Set("Authorization", "Basic "+encodedAuth) - req.Header.Set("Accept", "application/json") resp, err := client.Do(req) if err != nil { diff --git a/server/plugin.go b/server/plugin.go index 0558c5307..a248acd17 100644 --- a/server/plugin.go +++ b/server/plugin.go @@ -83,7 +83,7 @@ type externalConfig struct { // Display subscription name in notifications DisplaySubscriptionNameInNotifications bool - // The AES encryption key used to encrypt stored api tokens + // The encryption key used to encrypt stored api tokens EncryptionKey string // API token from Jira @@ -186,12 +186,17 @@ func (p *Plugin) OnConfigurationChange() error { } } - adminAPIToken := ec.AdminAPIToken - jsonBytes, err := json.Marshal(adminAPIToken) + jsonBytes, err := json.Marshal(ec.AdminAPIToken) if err != nil { return err } - encryptedAdminAPIToken, err := encrypt(jsonBytes, []byte(p.getConfig().EncryptionKey)) + + encryptionKey := p.getConfig().EncryptionKey + if encryptionKey == "" { + return errors.New("failed to encrypt admin token. Encryption key not generated") + } + + encryptedAdminAPIToken, err := encrypt(jsonBytes, []byte(encryptionKey)) if err != nil { return err } @@ -348,14 +353,6 @@ func (p *Plugin) SetupAutolink(instances *Instances) { continue } - ci, ciOk := instance.(*cloudInstance) - coi, coiOk := instance.(*cloudOAuthInstance) - - if !ciOk && !coiOk { - p.client.Log.Info("only cloud and cloud-oauth instances supported for autolink") - continue - } - var status *model.PluginStatus status, err = p.client.Plugin.GetPluginStatus(autolinkPluginID) if err != nil { @@ -368,18 +365,17 @@ func (p *Plugin) SetupAutolink(instances *Instances) { continue } - if ciOk { - if err = p.AddAutolinksForCloudInstance(ci); err != nil { - p.client.Log.Info("could not install autolinks for cloud instance", "instance", ci.BaseURL, "err", err) - continue + switch instance := instance.(type) { + case *cloudInstance: + if err = p.AddAutolinksForCloudInstance(instance); err != nil { + p.client.Log.Info("could not install autolinks for cloud instance", "instance", instance.BaseURL, "error", err) } - } - - if coiOk { - if err = p.AddAutolinksForCloudOAuthInstance(coi); err != nil { - p.client.Log.Info("could not install autolinks for cloud-oauth instance", "instance", coi.JiraBaseURL, "err", err) - continue + case *cloudOAuthInstance: + if err = p.AddAutolinksForCloudOAuthInstance(instance); err != nil { + p.client.Log.Info("could not install autolinks for cloud-oauth instance", "instance", instance.JiraBaseURL, "error", err) } + default: + p.client.Log.Info("only cloud and cloud-oauth instances supported for autolink") } } } diff --git a/server/plugin_test.go b/server/plugin_test.go index 137e8cc7f..075133591 100644 --- a/server/plugin_test.go +++ b/server/plugin_test.go @@ -156,22 +156,14 @@ func TestPlugin(t *testing.T) { } func TestSetupAutolink(t *testing.T) { - mockAPI := &plugintest.API{} - dummyInstanceStore := new(mockInstanceStore) - mockPluginClient := pluginapi.NewClient(mockAPI, nil) - p := &Plugin{ - client: mockPluginClient, - instanceStore: dummyInstanceStore, - } - tests := []struct { name string - setup func() + setup func(*Plugin, *plugintest.API, *mockInstanceStore) InstanceType InstanceType }{ { name: "Missing API token or Admin email", - setup: func() { + setup: func(p *Plugin, mockAPI *plugintest.API, dummyInstanceStore *mockInstanceStore) { mockAPI.On("LogInfo", "unable to setup autolink due to missing API Token or Admin Email").Return(nil).Times(1) dummyInstanceStore.On("LoadInstance", mock.Anything).Return(&serverInstance{}, nil).Times(1) @@ -184,7 +176,8 @@ func TestSetupAutolink(t *testing.T) { }, { name: "Unsupported instance type", - setup: func() { + setup: func(p *Plugin, mockAPI *plugintest.API, dummyInstanceStore *mockInstanceStore) { + mockAPI.On("GetPluginStatus", "mattermost-autolink").Return(&model.PluginStatus{State: model.PluginStateRunning}, nil).Times(1) mockAPI.On("LogInfo", "only cloud and cloud-oauth instances supported for autolink").Return(nil).Times(1) dummyInstanceStore.On("LoadInstance", mock.Anything).Return(&serverInstance{}, nil).Times(1) @@ -194,7 +187,7 @@ func TestSetupAutolink(t *testing.T) { }, { name: "Autolink plugin unavailable API returned error", - setup: func() { + setup: func(p *Plugin, mockAPI *plugintest.API, dummyInstanceStore *mockInstanceStore) { mockAPI.On("LogWarn", "OnActivate: Autolink plugin unavailable. API returned error", "error", mock.Anything).Return(nil).Times(1) mockAPI.On("GetPluginStatus", autolinkPluginID).Return(nil, &model.AppError{Message: "error getting plugin status"}).Times(1) dummyInstanceStore.On("LoadInstance", mock.Anything).Return(&cloudInstance{}, nil).Times(1) @@ -205,7 +198,7 @@ func TestSetupAutolink(t *testing.T) { }, { name: "Autolink plugin not running", - setup: func() { + setup: func(p *Plugin, mockAPI *plugintest.API, dummyInstanceStore *mockInstanceStore) { mockAPI.On("LogWarn", "OnActivate: Autolink plugin unavailable. Plugin is not running", "status", &model.PluginStatus{State: model.PluginStateNotRunning}).Return(nil).Times(1) mockAPI.On("GetPluginStatus", autolinkPluginID).Return(&model.PluginStatus{State: model.PluginStateNotRunning}, nil).Times(1) dummyInstanceStore.On("LoadInstance", mock.Anything).Return(&cloudInstance{}, nil).Times(1) @@ -216,8 +209,8 @@ func TestSetupAutolink(t *testing.T) { }, { name: "Error installing autolinks for cloud instance", - setup: func() { - mockAPI.On("LogInfo", "could not install autolinks for cloud instance", "instance", "mockBaseURL", "err", mock.Anything).Return(nil).Times(1) + setup: func(p *Plugin, mockAPI *plugintest.API, dummyInstanceStore *mockInstanceStore) { + mockAPI.On("LogInfo", "could not install autolinks for cloud instance", "instance", "mockBaseURL", "error", mock.Anything).Return(nil).Times(1) mockAPI.On("GetPluginStatus", autolinkPluginID).Return(&model.PluginStatus{State: model.PluginStateRunning}, nil).Times(1) dummyInstanceStore.On("LoadInstance", mock.Anything).Return( &cloudInstance{ @@ -238,9 +231,11 @@ func TestSetupAutolink(t *testing.T) { }, { name: "Error installing autolinks for cloud-oauth instance", - setup: func() { - mockAPI.On("LogInfo", "could not install autolinks for cloud-oauth instance", "instance", "mockBaseURL", "err", mock.Anything).Return(nil).Times(1) + setup: func(p *Plugin, mockAPI *plugintest.API, dummyInstanceStore *mockInstanceStore) { + mockAPI.On("LogWarn", "Error unmarshalling admin API token", "error", mock.Anything).Times(1) + mockAPI.On("LogInfo", "could not install autolinks for cloud-oauth instance", "instance", "mockBaseURL", "error", mock.Anything).Return(nil).Times(1) mockAPI.On("GetPluginStatus", autolinkPluginID).Return(&model.PluginStatus{State: model.PluginStateRunning}, nil).Times(1) + dummyInstanceStore.On("LoadInstance", mock.Anything).Return( &cloudOAuthInstance{ InstanceCommon: &InstanceCommon{ @@ -255,8 +250,16 @@ func TestSetupAutolink(t *testing.T) { }, } for _, tt := range tests { + mockAPI := &plugintest.API{} + dummyInstanceStore := new(mockInstanceStore) + mockPluginClient := pluginapi.NewClient(mockAPI, nil) + p := &Plugin{ + client: mockPluginClient, + instanceStore: dummyInstanceStore, + } + t.Run(tt.name, func(t *testing.T) { - tt.setup() + tt.setup(p, mockAPI, dummyInstanceStore) instances := GetInstancesWithType(tt.InstanceType) p.SetupAutolink(instances) diff --git a/server/utils.go b/server/utils.go index 21ba39089..e1c5251b9 100644 --- a/server/utils.go +++ b/server/utils.go @@ -7,7 +7,9 @@ import ( "crypto/rand" "crypto/sha256" "encoding/base64" + "encoding/json" "fmt" + "net/http" "regexp" "strings" @@ -182,3 +184,24 @@ func getS256PKCEParams() (*PKCEParams, error) { CodeVerifier: verifier, }, nil } + +func (p *Plugin) SetAdminAPITokenRequestHeader(req *http.Request) error { + encryptedAdminAPIToken := p.getConfig().AdminAPIToken + jsonBytes, err := decrypt([]byte(encryptedAdminAPIToken), []byte(p.getConfig().EncryptionKey)) + if err != nil { + p.client.Log.Warn("Error decrypting admin API token", "error", err) + return err + } + var adminAPIToken string + err = json.Unmarshal(jsonBytes, &adminAPIToken) + if err != nil { + p.client.Log.Warn("Error unmarshalling admin API token", "error", err) + return err + } + + encodedAuth := base64.StdEncoding.EncodeToString([]byte(p.getConfig().AdminEmail + ":" + adminAPIToken)) + req.Header.Set("Authorization", "Basic "+encodedAuth) + req.Header.Set("Accept", "application/json") + + return nil +} From bca94c3ef34b9248bc4241853593e483eee051ad Mon Sep 17 00:00:00 2001 From: kshitij katiyar Date: Thu, 5 Dec 2024 19:02:09 +0530 Subject: [PATCH 06/10] [MM-1137]: review fixes --- plugin.json | 6 +++--- server/plugin.go | 12 ++++++++++-- server/plugin_test.go | 1 - 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/plugin.json b/plugin.json index 79956ce2c..8186f178b 100644 --- a/plugin.json +++ b/plugin.json @@ -119,14 +119,14 @@ "key": "EncryptionKey", "display_name": "At Rest Encryption Key:", "type": "generated", - "help_text": "The encryption key used to encrypt stored api tokens.", + "help_text": "The encryption key used to encrypt stored API tokens.", "secret": true }, { "key": "AdminAPIToken", "display_name": "Admin API Token", "type": "text", - "help_text": "Set this [API token](https://support.atlassian.com/atlassian-account/docs/manage-api-tokens-for-your-atlassian-account/) to get notified for comment and issue created events even if the user triggering the event is not connected to Jira. This is also used for setting up autolink in the plugin.\n **Note:** API token should be created using an admin Jira account. Otherwise, the notification will not be delivered for the project the user cannot access and autolink will not work.", + "help_text": "Set this [API token](https://support.atlassian.com/atlassian-account/docs/manage-api-tokens-for-your-atlassian-account/) to get notified for comment and issue created events when the user triggering the event is not connected to Jira. This is also used for setting up autolink in the plugin.\n **Note:** API token should be created using an admin Jira account. Otherwise, the notification will not be delivered for the project the user cannot access and autolink will not work.", "placeholder": "", "secret": true, "default": "" @@ -135,7 +135,7 @@ "key": "AdminEmail", "display_name": "Admin Email", "type": "text", - "help_text": "**Note** Admin email is necessary to setup autolink for Jira plugin and to to get notified for comment and issue created events even if the user triggering the event is not connected to Jira", + "help_text": "**Note** Admin email is necessary to setup autolink for Jira plugin and to to get notified for comment and issue created events when the user triggering the event is not connected to Jira", "placeholder": "", "default": "" } diff --git a/server/plugin.go b/server/plugin.go index a248acd17..d151c259b 100644 --- a/server/plugin.go +++ b/server/plugin.go @@ -188,16 +188,19 @@ func (p *Plugin) OnConfigurationChange() error { jsonBytes, err := json.Marshal(ec.AdminAPIToken) if err != nil { + p.client.Log.Warn("Error marshaling the admin API token", "error", err) return err } encryptionKey := p.getConfig().EncryptionKey if encryptionKey == "" { + p.client.Log.Warn("Encryption key required to encrypt admin API token") return errors.New("failed to encrypt admin token. Encryption key not generated") } encryptedAdminAPIToken, err := encrypt(jsonBytes, []byte(encryptionKey)) if err != nil { + p.client.Log.Warn("Error encrypting the admin API token", "error", err) return err } ec.AdminAPIToken = string(encryptedAdminAPIToken) @@ -353,6 +356,13 @@ func (p *Plugin) SetupAutolink(instances *Instances) { continue } + switch instance.(type) { + case *cloudInstance, *cloudOAuthInstance: + default: + p.client.Log.Info("only cloud and cloud-oauth instances supported for autolink") + continue + } + var status *model.PluginStatus status, err = p.client.Plugin.GetPluginStatus(autolinkPluginID) if err != nil { @@ -374,8 +384,6 @@ func (p *Plugin) SetupAutolink(instances *Instances) { if err = p.AddAutolinksForCloudOAuthInstance(instance); err != nil { p.client.Log.Info("could not install autolinks for cloud-oauth instance", "instance", instance.JiraBaseURL, "error", err) } - default: - p.client.Log.Info("only cloud and cloud-oauth instances supported for autolink") } } } diff --git a/server/plugin_test.go b/server/plugin_test.go index 075133591..d1155859a 100644 --- a/server/plugin_test.go +++ b/server/plugin_test.go @@ -177,7 +177,6 @@ func TestSetupAutolink(t *testing.T) { { name: "Unsupported instance type", setup: func(p *Plugin, mockAPI *plugintest.API, dummyInstanceStore *mockInstanceStore) { - mockAPI.On("GetPluginStatus", "mattermost-autolink").Return(&model.PluginStatus{State: model.PluginStateRunning}, nil).Times(1) mockAPI.On("LogInfo", "only cloud and cloud-oauth instances supported for autolink").Return(nil).Times(1) dummyInstanceStore.On("LoadInstance", mock.Anything).Return(&serverInstance{}, nil).Times(1) From 7fccd8ee7fae0bbfc952ff2a87270cf964d6a6fc Mon Sep 17 00:00:00 2001 From: kshitij katiyar Date: Thu, 5 Dec 2024 20:00:39 +0530 Subject: [PATCH 07/10] [MM-1137]: log errors --- server/plugin.go | 8 ++++---- server/utils.go | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/server/plugin.go b/server/plugin.go index d151c259b..3fbfcbc67 100644 --- a/server/plugin.go +++ b/server/plugin.go @@ -188,7 +188,7 @@ func (p *Plugin) OnConfigurationChange() error { jsonBytes, err := json.Marshal(ec.AdminAPIToken) if err != nil { - p.client.Log.Warn("Error marshaling the admin API token", "error", err) + p.client.Log.Warn("Error marshaling the admin API token", "error", err.Error()) return err } @@ -200,7 +200,7 @@ func (p *Plugin) OnConfigurationChange() error { encryptedAdminAPIToken, err := encrypt(jsonBytes, []byte(encryptionKey)) if err != nil { - p.client.Log.Warn("Error encrypting the admin API token", "error", err) + p.client.Log.Warn("Error encrypting the admin API token", "error", err.Error()) return err } ec.AdminAPIToken = string(encryptedAdminAPIToken) @@ -378,11 +378,11 @@ func (p *Plugin) SetupAutolink(instances *Instances) { switch instance := instance.(type) { case *cloudInstance: if err = p.AddAutolinksForCloudInstance(instance); err != nil { - p.client.Log.Info("could not install autolinks for cloud instance", "instance", instance.BaseURL, "error", err) + p.client.Log.Info("could not install autolinks for cloud instance", "instance", instance.BaseURL, "error", err.Error()) } case *cloudOAuthInstance: if err = p.AddAutolinksForCloudOAuthInstance(instance); err != nil { - p.client.Log.Info("could not install autolinks for cloud-oauth instance", "instance", instance.JiraBaseURL, "error", err) + p.client.Log.Info("could not install autolinks for cloud-oauth instance", "instance", instance.JiraBaseURL, "error", err.Error()) } } } diff --git a/server/utils.go b/server/utils.go index e1c5251b9..0bda12115 100644 --- a/server/utils.go +++ b/server/utils.go @@ -189,13 +189,13 @@ func (p *Plugin) SetAdminAPITokenRequestHeader(req *http.Request) error { encryptedAdminAPIToken := p.getConfig().AdminAPIToken jsonBytes, err := decrypt([]byte(encryptedAdminAPIToken), []byte(p.getConfig().EncryptionKey)) if err != nil { - p.client.Log.Warn("Error decrypting admin API token", "error", err) + p.client.Log.Warn("Error decrypting admin API token", "error", err.Error()) return err } var adminAPIToken string err = json.Unmarshal(jsonBytes, &adminAPIToken) if err != nil { - p.client.Log.Warn("Error unmarshalling admin API token", "error", err) + p.client.Log.Warn("Error unmarshalling admin API token", "error", err.Error()) return err } From fbb2462ed4b77e941a182d252b3263aad66773fd Mon Sep 17 00:00:00 2001 From: Raghav Aggarwal Date: Tue, 17 Dec 2024 14:21:16 +0530 Subject: [PATCH 08/10] Review fixes Co-authored-by: Doug Lauder --- plugin.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugin.json b/plugin.json index 8186f178b..ec9d290cd 100644 --- a/plugin.json +++ b/plugin.json @@ -126,7 +126,7 @@ "key": "AdminAPIToken", "display_name": "Admin API Token", "type": "text", - "help_text": "Set this [API token](https://support.atlassian.com/atlassian-account/docs/manage-api-tokens-for-your-atlassian-account/) to get notified for comment and issue created events when the user triggering the event is not connected to Jira. This is also used for setting up autolink in the plugin.\n **Note:** API token should be created using an admin Jira account. Otherwise, the notification will not be delivered for the project the user cannot access and autolink will not work.", + "help_text": "Set this [API token](https://support.atlassian.com/atlassian-account/docs/manage-api-tokens-for-your-atlassian-account/) to get notified for comment and issue created events when the user triggering the event is not connected to Jira. This is also used for setting up autolink in the plugin.\n **Note:** API token should be created using an admin Jira account. Otherwise, the notification will not be delivered for projects that the user cannot access and autolink will not work.", "placeholder": "", "secret": true, "default": "" @@ -135,7 +135,7 @@ "key": "AdminEmail", "display_name": "Admin Email", "type": "text", - "help_text": "**Note** Admin email is necessary to setup autolink for Jira plugin and to to get notified for comment and issue created events when the user triggering the event is not connected to Jira", + "help_text": "**Note** Admin email is necessary to setup autolink for the Jira plugin and to to get notified for comment and issue created events when the user triggering the event is not connected to Jira", "placeholder": "", "default": "" } From d48c1190991e00bafd3d1f15c953c64440ed4456 Mon Sep 17 00:00:00 2001 From: Kshitij Katiyar Date: Tue, 7 Jan 2025 11:07:52 +0530 Subject: [PATCH 09/10] [MM-1137]: Fixed the secrets error found by QA --- plugin.json | 2 ++ server/plugin.go | 11 ++++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/plugin.json b/plugin.json index ec9d290cd..3474f71b1 100644 --- a/plugin.json +++ b/plugin.json @@ -120,6 +120,8 @@ "display_name": "At Rest Encryption Key:", "type": "generated", "help_text": "The encryption key used to encrypt stored API tokens.", + "placeholder": "", + "default": null, "secret": true }, { diff --git a/server/plugin.go b/server/plugin.go index 3fbfcbc67..51c30700a 100644 --- a/server/plugin.go +++ b/server/plugin.go @@ -192,7 +192,7 @@ func (p *Plugin) OnConfigurationChange() error { return err } - encryptionKey := p.getConfig().EncryptionKey + encryptionKey := ec.EncryptionKey if encryptionKey == "" { p.client.Log.Warn("Encryption key required to encrypt admin API token") return errors.New("failed to encrypt admin token. Encryption key not generated") @@ -540,6 +540,15 @@ func (c *externalConfig) setDefaults() (bool, error) { changed = true } + if c.EncryptionKey == "" { + encryptionKey, err := generateSecret() + if err != nil { + return false, err + } + c.EncryptionKey = encryptionKey + changed = true + } + return changed, nil } From 056a59fb14aa2e03c99cad85278ba82387b12b4a Mon Sep 17 00:00:00 2001 From: kshitij katiyar Date: Thu, 9 Jan 2025 15:45:50 +0530 Subject: [PATCH 10/10] [MM-1137]: Fixed the invalid log after successfully installing autolink --- server/plugin.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/server/plugin.go b/server/plugin.go index 51c30700a..22766170b 100644 --- a/server/plugin.go +++ b/server/plugin.go @@ -379,10 +379,14 @@ func (p *Plugin) SetupAutolink(instances *Instances) { case *cloudInstance: if err = p.AddAutolinksForCloudInstance(instance); err != nil { p.client.Log.Info("could not install autolinks for cloud instance", "instance", instance.BaseURL, "error", err.Error()) + } else { + p.client.Log.Info("successfully installed autolinks for cloud instance", "instance", instance.BaseURL) } case *cloudOAuthInstance: if err = p.AddAutolinksForCloudOAuthInstance(instance); err != nil { p.client.Log.Info("could not install autolinks for cloud-oauth instance", "instance", instance.JiraBaseURL, "error", err.Error()) + } else { + p.client.Log.Info("successfully installed autolinks for cloud-oauth instance", "instance", instance.JiraBaseURL) } } } @@ -441,7 +445,10 @@ func (p *Plugin) AddAutolinks(key, baseURL string) error { client := autolinkclient.NewClientPlugin(p.API) if err := client.Add(installList...); err != nil { - return fmt.Errorf("unable to add autolinks: %w", err) + // Do not return an error if the status code is 304 (indicating that the autolink for this project is already installed). + if !strings.Contains(err.Error(), `Error: 304, {"status": "OK"}`) { + return fmt.Errorf("unable to add autolinks: %w", err) + } } return nil