Allow pull request naming pattern customization (#28)

Fixes: #3

Signed-off-by: Steven Kriegler <sk.bunsenbrenner@gmail.com>
This commit is contained in:
justusbunsi 2022-06-18 14:03:56 +02:00
parent 4aad9c3e17
commit 685c834b61
No known key found for this signature in database
GPG Key ID: 82B29BF2507F9F8B
13 changed files with 254 additions and 19 deletions

View File

@ -5,6 +5,7 @@
### 🤖 Application
- Allow configuring listening port
- Allow changing naming pattern for Pull Requests
### 🐳 Docker image
@ -13,6 +14,7 @@
### ☸️ Helm Chart
- Add `.Values.app.listeningPort` parameter
- Add `.Values.app.configuration.namingPattern` parameters
## v0.2.0

View File

@ -57,3 +57,17 @@ projects:
gitea:
owner: justusbunsi
name: example-repo
# Define pull request names from SonarScanner analysis. Default pattern matches the Jenkins Gitea plugin schema.
namingPattern:
# Regular expression that MUST HAVE exactly ONE GROUP that matches the integer part of the PR.
# That integer part is identical to the pull request ID in Gitea.
regex: "^PR-(\\d+)$"
# Valid Go format string. It MUST have one integer placeholder which will be replaced by the pull request ID.
# See: https://pkg.go.dev/fmt#hdr-Printing
template: "PR-%d"
# Example for integer-only names
# # regex: "^(\\d+)$"
# # template: "%d"

View File

@ -53,20 +53,22 @@ You can find a full changelog in the [main repository](https://codeberg.org/just
### App parameters
| Name | Description | Value |
| ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ |
| `app.configLocationOverride` | Override the default location of the configuration file (`/home/bot/config/config.yaml`). **Available since Chart version `0.2.0`. Requires at least image tag `v0.2.0`**. (See values file for details) | `""` |
| `app.listeningPort` | Port the application will listening on inside the pod container. **Available since Chart version `0.2.1`. Requires at least image tag `v0.2.1`**. | `3000` |
| `app.configuration.gitea.url` | Endpoint of your Gitea instance. Must be expandable by '/api/v1' to form the API base path as shown in Swagger UI. | `""` |
| `app.configuration.gitea.token.value` | Gitea token as plain text. Can be replaced with `file` key containing path to file. | `""` |
| `app.configuration.sonarqube.url` | Endpoint of your SonarQube instance. Must be expandable by '/api' to form the API base path. | `""` |
| `app.configuration.sonarqube.token.value` | SonarQube token as plain text. Can be replaced with `file` key containing path to file. | `""` |
| `app.configuration.sonarqube.additionalMetrics` | Setting this option you can extend that default list by your own metrics. | `[]` |
| `app.configuration.projects[0].sonarqube.key` | Project key inside SonarQube | `""` |
| `app.configuration.projects[0].gitea.owner` | Repository owner inside Gitea | `""` |
| `app.configuration.projects[0].gitea.name` | Repository name inside Gitea | `""` |
| `volumes` | If token and webhook secrets shall be provided via file, volumes and volume mounts can be configured to setup the environment accordingly | `[]` |
| `volumeMounts` | If token and webhook secrets shall be provided via file, volumes and volume mounts can be configured to setup the environment accordingly | `[]` |
| Name | Description | Value |
| ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ |
| `app.configLocationOverride` | Override the default location of the configuration file (`/home/bot/config/config.yaml`). **Available since Chart version `0.2.0`. Requires at least image tag `v0.2.0`**. (See values file for details) | `""` |
| `app.listeningPort` | Port the application will listening on inside the pod container. **Available since Chart version `0.2.1`. Requires at least image tag `v0.2.1`**. | `3000` |
| `app.configuration.gitea.url` | Endpoint of your Gitea instance. Must be expandable by '/api/v1' to form the API base path as shown in Swagger UI. | `""` |
| `app.configuration.gitea.token.value` | Gitea token as plain text. Can be replaced with `file` key containing path to file. | `""` |
| `app.configuration.sonarqube.url` | Endpoint of your SonarQube instance. Must be expandable by '/api' to form the API base path. | `""` |
| `app.configuration.sonarqube.token.value` | SonarQube token as plain text. Can be replaced with `file` key containing path to file. | `""` |
| `app.configuration.sonarqube.additionalMetrics` | Setting this option you can extend that default list by your own metrics. | `[]` |
| `app.configuration.projects[0].sonarqube.key` | Project key inside SonarQube | `""` |
| `app.configuration.projects[0].gitea.owner` | Repository owner inside Gitea | `""` |
| `app.configuration.projects[0].gitea.name` | Repository name inside Gitea | `""` |
| `app.configuration.namingPattern.regex` | Regular expression that MUST HAVE exactly ONE GROUP that matches the integer part of the PR. That integer part is identical to the pull request ID in Gitea. | `^PR-(\d+)$` |
| `app.configuration.namingPattern.template` | Valid Go format string. It MUST have one integer placeholder which will be replaced by the pull request ID. See: https://pkg.go.dev/fmt#hdr-Printing | `PR-%d` |
| `volumes` | If token and webhook secrets shall be provided via file, volumes and volume mounts can be configured to setup the environment accordingly | `[]` |
| `volumeMounts` | If token and webhook secrets shall be provided via file, volumes and volume mounts can be configured to setup the environment accordingly | `[]` |
### Security parameters

View File

@ -129,6 +129,13 @@ app:
owner: ""
name: ""
# Define pull request names from SonarScanner analysis. Default pattern matches the Jenkins Gitea plugin schema.
# @param app.configuration.namingPattern.regex Regular expression that MUST HAVE exactly ONE GROUP that matches the integer part of the PR. That integer part is identical to the pull request ID in Gitea.
# @param app.configuration.namingPattern.template Valid Go format string. It MUST have one integer placeholder which will be replaced by the pull request ID. See: https://pkg.go.dev/fmt#hdr-Printing
namingPattern:
regex: "^PR-(\\d+)$"
template: "PR-%d"
# @param volumes If token and webhook secrets shall be provided via file, volumes and volume mounts can be configured to setup the environment accordingly
volumes: []
# - name: gitea-connection

View File

@ -40,6 +40,9 @@ func withValidGiteaSynchronizeRequestData(t *testing.T, jsonBody []byte) (*http.
}
func TestHandleGiteaCommentWebhookSuccess(t *testing.T) {
settings.Pattern = &settings.PatternConfig{
Template: "PR-%d",
}
settings.Gitea = settings.GiteaConfig{
Webhook: &settings.Webhook{
Secret: "",
@ -61,9 +64,16 @@ func TestHandleGiteaCommentWebhookSuccess(t *testing.T) {
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, `{"message": "Processing data. See bot logs for details."}`, rr.Body.String())
t.Cleanup(func() {
settings.Pattern = nil
})
}
func TestHandleGiteaCommentWebhookInvalidJSONBody(t *testing.T) {
settings.Pattern = &settings.PatternConfig{
Template: "PR-%d",
}
settings.Gitea = settings.GiteaConfig{
Webhook: &settings.Webhook{
Secret: "",
@ -82,6 +92,10 @@ func TestHandleGiteaCommentWebhookInvalidJSONBody(t *testing.T) {
assert.Equal(t, http.StatusUnprocessableEntity, rr.Code)
assert.Equal(t, `{"message": "Error parsing POST body."}`, rr.Body.String())
t.Cleanup(func() {
settings.Pattern = nil
})
}
func TestHandleGiteaCommentInvalidWebhookSignature(t *testing.T) {

View File

@ -4,6 +4,7 @@ import (
"bytes"
"net/http"
"net/http/httptest"
"regexp"
"testing"
"gitea-sonarqube-pr-bot/internal/settings"
@ -27,6 +28,9 @@ func withValidSonarQubeRequestData(t *testing.T, jsonBody []byte) (*http.Request
}
func TestHandleSonarQubeWebhookProjectMapped(t *testing.T) {
settings.Pattern = &settings.PatternConfig{
RegExp: regexp.MustCompile(`^PR-(\d+)$`),
}
settings.SonarQube = settings.SonarQubeConfig{
Webhook: &settings.Webhook{
Secret: "",
@ -44,6 +48,10 @@ func TestHandleSonarQubeWebhookProjectMapped(t *testing.T) {
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, `{"message": "Processing data. See bot logs for details."}`, rr.Body.String())
t.Cleanup(func() {
settings.Pattern = nil
})
}
func TestHandleSonarQubeWebhookProjectNotMapped(t *testing.T) {
@ -99,6 +107,9 @@ func TestHandleSonarQubeWebhookInvalidWebhookSignature(t *testing.T) {
}
func TestHandleSonarQubeWebhookForPullRequest(t *testing.T) {
settings.Pattern = &settings.PatternConfig{
RegExp: regexp.MustCompile(`^PR-(\d+)$`),
}
settings.SonarQube = settings.SonarQubeConfig{
Webhook: &settings.Webhook{
Secret: "",
@ -117,9 +128,16 @@ func TestHandleSonarQubeWebhookForPullRequest(t *testing.T) {
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, `{"message": "Processing data. See bot logs for details."}`, rr.Body.String())
t.Cleanup(func() {
settings.Pattern = nil
})
}
func TestHandleSonarQubeWebhookForBranch(t *testing.T) {
settings.Pattern = &settings.PatternConfig{
RegExp: regexp.MustCompile(`^PR-(\d+)$`),
}
settings.SonarQube = settings.SonarQubeConfig{
Webhook: &settings.Webhook{
Secret: "",
@ -138,4 +156,8 @@ func TestHandleSonarQubeWebhookForBranch(t *testing.T) {
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, `{"message": "Ignore Hook for non-PR analysis."}`, rr.Body.String())
t.Cleanup(func() {
settings.Pattern = nil
})
}

View File

@ -0,0 +1,14 @@
package sonarqube
import (
"io/ioutil"
"log"
"os"
"testing"
)
// SETUP: mute logs
func TestMain(m *testing.M) {
log.SetOutput(ioutil.Discard)
os.Exit(m.Run())
}

View File

@ -7,7 +7,6 @@ import (
"io"
"log"
"net/http"
"regexp"
"strconv"
"strings"
@ -16,17 +15,16 @@ import (
)
func ParsePRIndex(name string) (int, error) {
re := regexp.MustCompile(`^PR-(\d+)$`)
res := re.FindSubmatch([]byte(name))
res := settings.Pattern.RegExp.FindSubmatch([]byte(name))
if len(res) != 2 {
return 0, fmt.Errorf("branch name '%s' does not match regex '%s'", name, re.String())
return 0, fmt.Errorf("branch name '%s' does not match regex '%s'", name, settings.Pattern.RegExp.String())
}
return strconv.Atoi(string(res[1]))
}
func PRNameFromIndex(index int64) string {
return fmt.Sprintf("PR-%d", index)
return fmt.Sprintf(settings.Pattern.Template, index)
}
func GetRenderedQualityGate(qg string) string {

View File

@ -5,6 +5,7 @@ import (
"io"
"net/http"
"net/http/httptest"
"regexp"
"testing"
"gitea-sonarqube-pr-bot/internal/settings"
@ -28,17 +29,41 @@ func (c *ClientMock) Do(req *http.Request) (*http.Response, error) {
}
func TestParsePRIndexSuccess(t *testing.T) {
settings.Pattern = &settings.PatternConfig{
RegExp: regexp.MustCompile(`^PR-(\d+)$`),
}
actual, _ := ParsePRIndex("PR-1337")
assert.Equal(t, 1337, actual, "PR index parsing is broken")
t.Cleanup(func() {
settings.Pattern = nil
})
}
func TestParsePRIndexNonIntegerFailure(t *testing.T) {
settings.Pattern = &settings.PatternConfig{
RegExp: regexp.MustCompile(`^PR-(\d+)$`),
}
_, err := ParsePRIndex("PR-invalid")
assert.EqualErrorf(t, err, "branch name 'PR-invalid' does not match regex '^PR-(\\d+)$'", "Integer parsing succeeds unexpectedly")
t.Cleanup(func() {
settings.Pattern = nil
})
}
func TestPRNameFromIndex(t *testing.T) {
settings.Pattern = &settings.PatternConfig{
Template: "PR-%d",
}
assert.Equal(t, "PR-1337", PRNameFromIndex(1337))
t.Cleanup(func() {
settings.Pattern = nil
})
}
func TestGetRenderedQualityGateSuccess(t *testing.T) {
@ -57,9 +82,16 @@ func TestGetPullRequestUrl(t *testing.T) {
sdk := &SonarQubeSdk{
baseUrl: "https://sonarqube.example.com",
}
settings.Pattern = &settings.PatternConfig{
Template: "PR-%d",
}
actual := sdk.GetPullRequestUrl("test-project", 1337)
assert.Equal(t, "https://sonarqube.example.com/dashboard?id=test-project&pullRequest=PR-1337", actual, "PR Dashboard URL building broken")
t.Cleanup(func() {
settings.Pattern = nil
})
}
func TestRetrieveDataFromApiSuccess(t *testing.T) {
@ -259,6 +291,9 @@ func TestFetchPullRequestsErrorsInResponse(t *testing.T) {
}
func TestGetPullRequestSuccess(t *testing.T) {
settings.Pattern = &settings.PatternConfig{
Template: "PR-%d",
}
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"pullRequests":[{"key":"PR-1","title":"pr-branch","branch":"pr-branch","base":"main","status":{"qualityGateStatus":"OK","bugs":0,"vulnerabilities":0,"codeSmells":0},"analysisDate":"2022-06-12T11:23:09+0000","target":"main"}]}`))
})
@ -279,6 +314,10 @@ func TestGetPullRequestSuccess(t *testing.T) {
assert.Nil(t, err, "Successful data retrieval broken and throws error")
assert.IsType(t, &PullRequest{}, actual, "Happy path broken")
t.Cleanup(func() {
settings.Pattern = nil
})
}
func TestGetPullRequestFetchError(t *testing.T) {
@ -306,6 +345,10 @@ func TestGetPullRequestFetchError(t *testing.T) {
}
func TestGetPullRequestUnknownPR(t *testing.T) {
settings.Pattern = &settings.PatternConfig{
Template: "PR-%d",
}
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"pullRequests":[{"key":"PR-1","title":"pr-branch","branch":"pr-branch","base":"main","status":{"qualityGateStatus":"OK","bugs":0,"vulnerabilities":0,"codeSmells":0},"analysisDate":"2022-06-12T11:23:09+0000","target":"main"}]}`))
})
@ -325,6 +368,10 @@ func TestGetPullRequestUnknownPR(t *testing.T) {
_, err := sdk.GetPullRequest("test-project", 1337)
assert.Errorf(t, err, "no pull request found with name 'PR-1337'")
t.Cleanup(func() {
settings.Pattern = nil
})
}
func TestGetMeasuresSuccess(t *testing.T) {

View File

@ -0,0 +1,8 @@
package settings
import "regexp"
type PatternConfig struct {
RegExp *regexp.Regexp
Template string
}

View File

@ -2,6 +2,7 @@ package settings
import (
"fmt"
"regexp"
"strings"
"github.com/spf13/viper"
@ -11,6 +12,7 @@ var (
Gitea GiteaConfig
SonarQube SonarQubeConfig
Projects []Project
Pattern *PatternConfig
)
func newConfigReader(configFile string) *viper.Viper {
@ -33,6 +35,8 @@ func newConfigReader(configFile string) *viper.Viper {
v.SetDefault("sonarqube.webhook.secretFile", "")
v.SetDefault("sonarqube.additionalMetrics", []string{})
v.SetDefault("projects", []Project{})
v.SetDefault("namingPattern.regex", `^PR-(\d+)$`)
v.SetDefault("namingPattern.template", "PR-%d")
return v
}
@ -71,4 +75,8 @@ func Load(configFile string) {
Webhook: NewWebhook(r.GetString, "sonarqube", errCallback),
AdditionalMetrics: r.GetStringSlice("sonarqube.additionalMetrics"),
}
Pattern = &PatternConfig{
RegExp: regexp.MustCompile(r.GetString("namingPattern.regex")),
Template: r.GetString("namingPattern.template"),
}
}

View File

@ -4,6 +4,7 @@ import (
"io/ioutil"
"os"
"path"
"regexp"
"testing"
"github.com/stretchr/testify/assert"
@ -29,6 +30,9 @@ projects:
gitea:
owner: example-organization
name: pr-bot
namingPattern:
regex: "^PR-(\\d+)$"
template: "PR-%d"
`)
func WriteConfigFile(t *testing.T, content []byte) string {
@ -289,3 +293,79 @@ projects: []
assert.Panics(t, func() { Load(c) }, "No panic for empty project mapping that is required")
}
func TestLoadNamingPatternStructure(t *testing.T) {
c := WriteConfigFile(t, defaultConfig)
Load(c)
expected := &PatternConfig{
RegExp: regexp.MustCompile(`^PR-(\d+)$`),
Template: "PR-%d",
}
assert.EqualValues(t, expected, Pattern)
}
func TestLoadNamingPatternStructureWithInternalDefaults(t *testing.T) {
c := WriteConfigFile(t, []byte(
`gitea:
url: https://example.com/gitea
token:
value: fake-gitea-token
sonarqube:
url: https://example.com/sonarqube
token:
value: fake-sonarqube-token
additionalMetrics: "new_security_hotspots"
projects:
- sonarqube:
key: gitea-sonarqube-pr-bot
gitea:
owner: example-organization
name: pr-bot
`))
Load(c)
expected := &PatternConfig{
RegExp: regexp.MustCompile(`^PR-(\d+)$`),
Template: "PR-%d",
}
assert.EqualValues(t, expected, Pattern)
}
func TestLoadNamingPatternStructureInjectedEnvs(t *testing.T) {
os.Setenv("PRBOT_NAMINGPATTERN_REGEX", "test-(\\d+)-pullrequest")
os.Setenv("PRBOT_NAMINGPATTERN_TEMPLATE", "test-%d-pullrequest")
c := WriteConfigFile(t, defaultConfig)
Load(c)
expected := &PatternConfig{
RegExp: regexp.MustCompile(`test-(\d+)-pullrequest`),
Template: "test-%d-pullrequest",
}
assert.EqualValues(t, expected, Pattern)
t.Cleanup(func() {
os.Unsetenv("PRBOT_NAMINGPATTERN_REGEX")
os.Unsetenv("PRBOT_NAMINGPATTERN_TEMPLATE")
})
}
func TestLoadNamingPatternStructureMixedInput(t *testing.T) {
os.Setenv("PRBOT_NAMINGPATTERN_REGEX", "test-(\\d+)-pullrequest")
c := WriteConfigFile(t, defaultConfig)
Load(c)
expected := &PatternConfig{
RegExp: regexp.MustCompile(`test-(\d+)-pullrequest`),
Template: "PR-%d",
}
assert.EqualValues(t, expected, Pattern)
t.Cleanup(func() {
os.Unsetenv("PRBOT_NAMINGPATTERN_REGEX")
})
}

View File

@ -1,18 +1,29 @@
package sonarqube
import (
"regexp"
"testing"
"gitea-sonarqube-pr-bot/internal/settings"
"github.com/stretchr/testify/assert"
)
func TestNewWebhook(t *testing.T) {
settings.Pattern = &settings.PatternConfig{
RegExp: regexp.MustCompile(`^PR-(\d+)$`),
}
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 := New(raw)
assert.NotNil(t, response)
assert.Equal(t, 1337, response.PRIndex)
assert.True(t, ok)
t.Cleanup(func() {
settings.Pattern = nil
})
}
func TestNewWebhookInvalidJSON(t *testing.T) {
@ -23,8 +34,16 @@ func TestNewWebhookInvalidJSON(t *testing.T) {
}
func TestNewWebhookInvalidBranchName(t *testing.T) {
settings.Pattern = &settings.PatternConfig{
RegExp: regexp.MustCompile(`^PR-(\d+)$`),
}
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": "invalid", "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": {} }`)
_, ok := New(raw)
assert.False(t, ok)
t.Cleanup(func() {
settings.Pattern = nil
})
}