diff --git a/internal/settings/settings.go b/internal/settings/settings.go index 04c9dc4..208065d 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -48,33 +48,6 @@ var ( Projects []Project ) -func init() { - viper.SetConfigName("config.yaml") - viper.SetConfigType("yaml") - viper.SetEnvPrefix("prbot") - viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) - viper.AllowEmptyEnv(true) - viper.AutomaticEnv() - - ApplyConfigDefaults() -} - -func ApplyConfigDefaults() { - viper.SetDefault("gitea.url", "") - viper.SetDefault("gitea.token.value", "") - viper.SetDefault("gitea.token.file", "") - viper.SetDefault("gitea.webhook.secret", "") - viper.SetDefault("gitea.webhook.secretFile", "") - - viper.SetDefault("sonarqube.url", "") - viper.SetDefault("sonarqube.token.value", "") - viper.SetDefault("sonarqube.token.file", "") - viper.SetDefault("sonarqube.webhook.secret", "") - viper.SetDefault("sonarqube.webhook.secretFile", "") - - viper.SetDefault("projects", []Project{}) -} - func ReadSecretFile(file string, defaultValue string) (string) { if file == "" { return defaultValue @@ -88,10 +61,35 @@ func ReadSecretFile(file string, defaultValue string) (string) { return string(content) } -func Load(configPath string) { - viper.AddConfigPath(configPath) +func NewConfigReader() *viper.Viper { + v := viper.New() + v.SetConfigName("config.yaml") + v.SetConfigType("yaml") + v.SetEnvPrefix("prbot") + v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) + v.AllowEmptyEnv(true) + v.AutomaticEnv() - err := viper.ReadInConfig() + v.SetDefault("gitea.url", "") + v.SetDefault("gitea.token.value", "") + v.SetDefault("gitea.token.file", "") + v.SetDefault("gitea.webhook.secret", "") + v.SetDefault("gitea.webhook.secretFile", "") + v.SetDefault("sonarqube.url", "") + v.SetDefault("sonarqube.token.value", "") + v.SetDefault("sonarqube.token.file", "") + v.SetDefault("sonarqube.webhook.secret", "") + v.SetDefault("sonarqube.webhook.secretFile", "") + v.SetDefault("projects", []Project{}) + + return v +} + +func Load(configPath string) { + r := NewConfigReader() + r.AddConfigPath(configPath) + + err := r.ReadInConfig() if err != nil { panic(fmt.Errorf("Fatal error while reading config file: %w \n", err)) } @@ -102,7 +100,7 @@ func Load(configPath string) { Projects []Project } - err = viper.Unmarshal(&fullConfig) + err = r.Unmarshal(&fullConfig) if err != nil { panic(fmt.Errorf("Unable to load config into struct, %v", err)) } diff --git a/internal/webhooks/sonarqube/main_test.go b/internal/webhooks/sonarqube/main_test.go new file mode 100644 index 0000000..f3d2576 --- /dev/null +++ b/internal/webhooks/sonarqube/main_test.go @@ -0,0 +1,14 @@ +package sonarqube + +import ( + "log" + "io/ioutil" + "os" + "testing" +) + +// SETUP: mute logs +func TestMain(m *testing.M) { + log.SetOutput(ioutil.Discard) + os.Exit(m.Run()) +} diff --git a/internal/webhooks/sonarqube/sonarqube.go b/internal/webhooks/sonarqube/sonarqube.go new file mode 100644 index 0000000..5cdacd6 --- /dev/null +++ b/internal/webhooks/sonarqube/sonarqube.go @@ -0,0 +1,61 @@ +package sonarqube + +import ( + "fmt" + "log" + "io" + "io/ioutil" + "net/http" + + "github.com/justusbunsi/gitea-sonarqube-pr-bot/internal/settings" +) + +func inProjectsMapping(p []settings.Project, n string) bool { + for _, proj := range p { + if proj.SonarQube.Key == n { + return true + } + } + + return false +} + +func HandleWebhook(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("Content-Type", "application/json") + + project := r.Header.Get("X-SonarQube-Project") + if !inProjectsMapping(settings.Projects, project) { + log.Printf("Received hook for project '%s' which is not configured. Request ignored.", project) + + rw.WriteHeader(http.StatusOK) + io.WriteString(rw, fmt.Sprintf(`{"message": "Project '%s' not in configured list. Request ignored."}`, project)) + return + } + + log.Printf("Received hook for project '%s'. Processing data.", project) + + var raw []byte + var webhook *Webhook + var ok bool + var err error + + raw, err = ioutil.ReadAll(r.Body) + defer r.Body.Close() + if err != nil { + rw.WriteHeader(http.StatusInternalServerError) + io.WriteString(rw, fmt.Sprintf(`{"message": "%s"}`, err.Error())) + return + } + + if webhook, ok = NewWebhook(raw); !ok { + rw.WriteHeader(http.StatusUnprocessableEntity) + io.WriteString(rw, `{"message": "Error parsing POST body."}`) + return + } + + log.Printf("%s", webhook) + + // Send response to SonarQube at this point to ensure being within 10 seconds limit of webhook response timeout + rw.WriteHeader(http.StatusOK) + io.WriteString(rw, `{"message": "Processing data. See bot logs for details."}`) +} diff --git a/internal/webhooks/sonarqube/sonarqube_test.go b/internal/webhooks/sonarqube/sonarqube_test.go new file mode 100644 index 0000000..5af8e4b --- /dev/null +++ b/internal/webhooks/sonarqube/sonarqube_test.go @@ -0,0 +1,79 @@ +package sonarqube + +import ( + "bytes" + "net/http" + "net/http/httptest" + "testing" + + "github.com/justusbunsi/gitea-sonarqube-pr-bot/internal/settings" + "github.com/stretchr/testify/assert" +) + +func withValidRequestData(t *testing.T) (*http.Request, *httptest.ResponseRecorder, http.HandlerFunc) { + jsonBody := []byte(`{ "serverUrl": "https://example.com/sonarqube", "taskId": "AXouyxDpizdp4B1K", "status": "SUCCESS", "analysedAt": "2021-05-21T12:12:07+0000", "revision": "f84442009c09b1adc278b6aa80a3853419f54007", "changedAt": "2021-05-21T12:12:07+0000", "project": { "key": "pr-bot", "name": "PR Bot", "url": "https://example.com/sonarqube/dashboard?id=pr-bot" }, "branch": { "name": "PR-1337", "type": "PULL_REQUEST", "isMain": false, "url": "https://example.com/sonarqube/dashboard?id=pr-bot&pullRequest=PR-1337" }, "qualityGate": { "name": "PR Bot", "status": "OK", "conditions": [ { "metric": "new_reliability_rating", "operator": "GREATER_THAN", "value": "1", "status": "OK", "errorThreshold": "1" }, { "metric": "new_security_rating", "operator": "GREATER_THAN", "value": "1", "status": "OK", "errorThreshold": "1" }, { "metric": "new_maintainability_rating", "operator": "GREATER_THAN", "value": "1", "status": "OK", "errorThreshold": "1" }, { "metric": "new_security_hotspots_reviewed", "operator": "LESS_THAN", "status": "NO_VALUE", "errorThreshold": "100" } ] }, "properties": {} }`) + req, err := http.NewRequest("POST", "/hooks/sonarqube", bytes.NewBuffer(jsonBody)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("X-SonarQube-Project", "pr-bot") + + rr := httptest.NewRecorder() + handler := http.HandlerFunc(HandleWebhook) + + return req, rr, handler +} + +func TestHandleWebhookProjectMapped(t *testing.T) { + settings.Projects = []settings.Project{ + settings.Project{ + SonarQube: struct{Key string}{ + Key: "pr-bot", + }, + }, + } + req, rr, handler := withValidRequestData(t) + handler.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + assert.Equal(t, `{"message": "Processing data. See bot logs for details."}`, rr.Body.String()) +} + +func TestHandleWebhookProjectNotMapped(t *testing.T) { + settings.Projects = []settings.Project{ + settings.Project{ + SonarQube: struct{Key string}{ + Key: "another-project", + }, + }, + } + req, rr, handler := withValidRequestData(t) + handler.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + assert.Equal(t, `{"message": "Project 'pr-bot' not in configured list. Request ignored."}`, rr.Body.String()) +} + +func TestHandleWebhookInvalidJSONBody(t *testing.T) { + settings.Projects = []settings.Project{ + settings.Project{ + SonarQube: struct{Key string}{ + Key: "pr-bot", + }, + }, + } + + jsonBody := []byte(`{ "serverUrl": ["invalid-server-url-content"] }`) + req, err := http.NewRequest("POST", "/hooks/sonarqube", bytes.NewBuffer(jsonBody)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("X-SonarQube-Project", "pr-bot") + + rr := httptest.NewRecorder() + handler := http.HandlerFunc(HandleWebhook) + handler.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusUnprocessableEntity, rr.Code) + assert.Equal(t, `{"message": "Error parsing POST body."}`, rr.Body.String()) +} diff --git a/internal/webhooks/sonarqube/webhook.go b/internal/webhooks/sonarqube/webhook.go new file mode 100644 index 0000000..0e86693 --- /dev/null +++ b/internal/webhooks/sonarqube/webhook.go @@ -0,0 +1,47 @@ +package sonarqube + +import ( + "bytes" + "log" + + "github.com/spf13/viper" +) + +type Webhook struct { + ServerUrl string `mapstructure:"serverUrl"` + Revision string + Branch Branch + QualityGate QualityGate `mapstructure:"qualityGate"` +} + +type Branch struct { + Name string + Type string + Url string +} + +type QualityGate struct { + Status string + Conditions []QualityGateCondition +} + +type QualityGateCondition struct { + Metric string + Status string +} + +func NewWebhook(raw []byte) (*Webhook, bool) { + v := viper.New() + v.SetConfigType("json") + v.ReadConfig(bytes.NewBuffer(raw)) + + w := Webhook{} + + err := v.Unmarshal(&w) + if err != nil { + log.Printf("Error parsing SonarQube webhook: %s", err.Error()) + return nil, false + } + + return &w, true +} diff --git a/internal/webhooks/sonarqube/webhook_test.go b/internal/webhooks/sonarqube/webhook_test.go new file mode 100644 index 0000000..30164f8 --- /dev/null +++ b/internal/webhooks/sonarqube/webhook_test.go @@ -0,0 +1,23 @@ +package sonarqube + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewWebhook(t *testing.T) { + raw := []byte(`{ "serverUrl": "https://example.com/sonarqube", "taskId": "AXouyxDpizdp4B1K", "status": "SUCCESS", "analysedAt": "2021-05-21T12:12:07+0000", "revision": "f84442009c09b1adc278b6aa80a3853419f54007", "changedAt": "2021-05-21T12:12:07+0000", "project": { "key": "pr-bot", "name": "PR Bot", "url": "https://example.com/sonarqube/dashboard?id=pr-bot" }, "branch": { "name": "PR-1337", "type": "PULL_REQUEST", "isMain": false, "url": "https://example.com/sonarqube/dashboard?id=pr-bot&pullRequest=PR-1337" }, "qualityGate": { "name": "PR Bot", "status": "OK", "conditions": [ { "metric": "new_reliability_rating", "operator": "GREATER_THAN", "value": "1", "status": "OK", "errorThreshold": "1" }, { "metric": "new_security_rating", "operator": "GREATER_THAN", "value": "1", "status": "OK", "errorThreshold": "1" }, { "metric": "new_maintainability_rating", "operator": "GREATER_THAN", "value": "1", "status": "OK", "errorThreshold": "1" }, { "metric": "new_security_hotspots_reviewed", "operator": "LESS_THAN", "status": "NO_VALUE", "errorThreshold": "100" } ] }, "properties": {} }`) + response, ok := NewWebhook(raw) + + assert.NotNil(t, response) + assert.True(t, ok) +} + +func TestNewWebhookInvalidJSON(t *testing.T) { + raw := []byte(`{ "serverUrl": ["invalid-server-url-content"] }`) + response, ok := NewWebhook(raw) + + assert.Nil(t, response) + assert.False(t, ok) +}