diff --git a/cmd/pint/ci.go b/cmd/pint/ci.go index f6c3ab09..fee8d2c6 100644 --- a/cmd/pint/ci.go +++ b/cmd/pint/ci.go @@ -165,17 +165,15 @@ func actionCI(ctx context.Context, c *cli.Command) error { timeout, _ := time.ParseDuration(meta.cfg.Repository.BitBucket.Timeout) br := reporter.NewBitBucketReporter( - version, meta.cfg.Repository.BitBucket.URI, timeout, token, meta.cfg.Repository.BitBucket.Project, meta.cfg.Repository.BitBucket.Repository, meta.cfg.Repository.BitBucket.MaxComments, - c.Bool(showDupsFlag), git.RunGit, ) - reps = append(reps, br) + reps = append(reps, reporter.NewCommentReporter(br, c.Bool(showDupsFlag))) } if meta.cfg.Repository != nil && meta.cfg.Repository.GitLab != nil { diff --git a/cmd/pint/tests/0031_ci_bitbucket.txt b/cmd/pint/tests/0031_ci_bitbucket.txt index 850e77e8..1e6ad985 100644 --- a/cmd/pint/tests/0031_ci_bitbucket.txt +++ b/cmd/pint/tests/0031_ci_bitbucket.txt @@ -1,4 +1,3 @@ -http response bitbucket /rest/insights/1.0/projects/prometheus/repos/rules/commits/.*/reports/pint 200 OK http response bitbucket /rest/api/1.0/projects/prometheus/repos/rules/commits/.*/pull-requests 200 {} http start bitbucket 127.0.0.1:6031 @@ -55,72 +54,8 @@ repository { } -- bitbucket.expected -- -DELETE /rest/insights/1.0/projects/prometheus/repos/rules/commits/.*/reports/pint - Accept-Encoding: gzip - Authorization: Bearer "12345" - Content-Type: application/json - -PUT /rest/insights/1.0/projects/prometheus/repos/rules/commits/.*/reports/pint - Accept-Encoding: gzip - Authorization: Bearer "12345" - Content-Type: application/json ---- BODY --- -reporter: Prometheus rule linter -title: pint unknown -result: PASS -details: |- - pint is a Prometheus rule linter/validator. - It will inspect all Prometheus recording and alerting rules for problems that could prevent these from working correctly. - Checks can be either offline (static checks using only rule definition) or online (validate rule against live Prometheus server). -link: https://cloudflare.github.io/pint/ -data: - - value: 2 - title: Number of rules parsed - type: NUMBER - - value: 2 - title: Number of rules checked - type: NUMBER - - value: 2 - title: Number of problems found - type: NUMBER - - value: 9 - title: Number of offline checks - type: NUMBER - - value: 0 - title: Number of online checks - type: NUMBER - - value: 0 - title: Checks duration - type: DURATION ---- END --- - GET /rest/api/1.0/projects/prometheus/repos/rules/commits/.*/pull-requests Accept-Encoding: gzip Authorization: Bearer "12345" Content-Type: application/json -DELETE /rest/insights/1.0/projects/prometheus/repos/rules/commits/.*/reports/pint - Accept-Encoding: gzip - Authorization: Bearer "12345" - Content-Type: application/json - -POST /rest/insights/1.0/projects/prometheus/repos/rules/commits/.*/reports/pint - Accept-Encoding: gzip - Authorization: Bearer "12345" - Content-Type: application/json ---- BODY --- -annotations: - - path: rules.yml - message: "Problem reported on unmodified line 2, annotation moved here: alerts/comparison: always firing alert" - severity: LOW - type: CODE_SMELL - link: https://cloudflare.github.io/pint/checks/alerts/comparison.html - line: 3 - - path: rules.yml - message: "alerts/for: redundant field with default value" - severity: LOW - type: CODE_SMELL - link: https://cloudflare.github.io/pint/checks/alerts/for.html - line: 3 ---- END --- - diff --git a/cmd/pint/tests/0068_skip_ci.txt b/cmd/pint/tests/0068_skip_ci.txt index 1c292a96..190071ca 100644 --- a/cmd/pint/tests/0068_skip_ci.txt +++ b/cmd/pint/tests/0068_skip_ci.txt @@ -1,4 +1,3 @@ -http response bitbucket /rest/insights/1.0/projects/prometheus/repos/rules/commits/.*/reports/pint 200 OK http response bitbucket /rest/api/1.0/projects/prometheus/repos/rules/commits/.*/pull-requests 200 {} http start bitbucket 127.0.0.1:6068 @@ -54,52 +53,8 @@ repository { } -- bitbucket.expected -- -DELETE /rest/insights/1.0/projects/prometheus/repos/rules/commits/.*/reports/pint - Accept-Encoding: gzip - Authorization: Bearer "12345" - Content-Type: application/json - -PUT /rest/insights/1.0/projects/prometheus/repos/rules/commits/.*/reports/pint - Accept-Encoding: gzip - Authorization: Bearer "12345" - Content-Type: application/json ---- BODY --- -reporter: Prometheus rule linter -title: pint unknown -result: PASS -details: |- - pint is a Prometheus rule linter/validator. - It will inspect all Prometheus recording and alerting rules for problems that could prevent these from working correctly. - Checks can be either offline (static checks using only rule definition) or online (validate rule against live Prometheus server). -link: https://cloudflare.github.io/pint/ -data: - - value: 0 - title: Number of rules parsed - type: NUMBER - - value: 0 - title: Number of rules checked - type: NUMBER - - value: 0 - title: Number of problems found - type: NUMBER - - value: 0 - title: Number of offline checks - type: NUMBER - - value: 0 - title: Number of online checks - type: NUMBER - - value: 0 - title: Checks duration - type: DURATION ---- END --- - GET /rest/api/1.0/projects/prometheus/repos/rules/commits/.*/pull-requests Accept-Encoding: gzip Authorization: Bearer "12345" Content-Type: application/json -DELETE /rest/insights/1.0/projects/prometheus/repos/rules/commits/.*/reports/pint - Accept-Encoding: gzip - Authorization: Bearer "12345" - Content-Type: application/json - diff --git a/cmd/pint/tests/0069_bitbucket_unmodified.txt b/cmd/pint/tests/0069_bitbucket_unmodified.txt index d5e1d507..639d6814 100644 --- a/cmd/pint/tests/0069_bitbucket_unmodified.txt +++ b/cmd/pint/tests/0069_bitbucket_unmodified.txt @@ -1,4 +1,3 @@ -http response bitbucket /rest/insights/1.0/projects/prometheus/repos/rules/commits/.*/reports/pint 200 OK http response bitbucket /rest/api/1.0/projects/prometheus/repos/rules/commits/.*/pull-requests 200 {} http start bitbucket 127.0.0.1:6069 @@ -73,96 +72,8 @@ repository { } -- bitbucket.expected -- -DELETE /rest/insights/1.0/projects/prometheus/repos/rules/commits/.*/reports/pint - Accept-Encoding: gzip - Authorization: Bearer "12345" - Content-Type: application/json - -PUT /rest/insights/1.0/projects/prometheus/repos/rules/commits/.*/reports/pint - Accept-Encoding: gzip - Authorization: Bearer "12345" - Content-Type: application/json ---- BODY --- -reporter: Prometheus rule linter -title: pint unknown -result: PASS -details: |- - pint is a Prometheus rule linter/validator. - It will inspect all Prometheus recording and alerting rules for problems that could prevent these from working correctly. - Checks can be either offline (static checks using only rule definition) or online (validate rule against live Prometheus server). -link: https://cloudflare.github.io/pint/ -data: - - value: 4 - title: Number of rules parsed - type: NUMBER - - value: 4 - title: Number of rules checked - type: NUMBER - - value: 6 - title: Number of problems found - type: NUMBER - - value: 20 - title: Number of offline checks - type: NUMBER - - value: 0 - title: Number of online checks - type: NUMBER - - value: 0 - title: Checks duration - type: DURATION ---- END --- - GET /rest/api/1.0/projects/prometheus/repos/rules/commits/.*/pull-requests Accept-Encoding: gzip Authorization: Bearer "12345" Content-Type: application/json -DELETE /rest/insights/1.0/projects/prometheus/repos/rules/commits/.*/reports/pint - Accept-Encoding: gzip - Authorization: Bearer "12345" - Content-Type: application/json - -POST /rest/insights/1.0/projects/prometheus/repos/rules/commits/.*/reports/pint - Accept-Encoding: gzip - Authorization: Bearer "12345" - Content-Type: application/json ---- BODY --- -annotations: - - path: rules.yml - message: "Problem reported on unmodified line 2, annotation moved here: alerts/comparison: always firing alert" - severity: LOW - type: CODE_SMELL - link: https://cloudflare.github.io/pint/checks/alerts/comparison.html - line: 1 - - path: rules.yml - message: "Problem reported on unmodified line 2, annotation moved here: promql/regexp: redundant regexp" - severity: LOW - type: CODE_SMELL - link: https://cloudflare.github.io/pint/checks/promql/regexp.html - line: 1 - - path: rules.yml - message: "alerts/for: redundant field with default value" - severity: LOW - type: CODE_SMELL - link: https://cloudflare.github.io/pint/checks/alerts/for.html - line: 3 - - path: rules.yml - message: "Problem reported on unmodified line 5, annotation moved here: alerts/comparison: always firing alert" - severity: LOW - type: CODE_SMELL - link: https://cloudflare.github.io/pint/checks/alerts/comparison.html - line: 4 - - path: rules.yml - message: "Problem reported on unmodified line 5, annotation moved here: promql/regexp: redundant regexp" - severity: LOW - type: CODE_SMELL - link: https://cloudflare.github.io/pint/checks/promql/regexp.html - line: 4 - - path: rules.yml - message: "Problem reported on unmodified line 6, annotation moved here: alerts/for: redundant field with default value" - severity: LOW - type: CODE_SMELL - link: https://cloudflare.github.io/pint/checks/alerts/for.html - line: 4 ---- END --- - diff --git a/cmd/pint/tests/0070_bitbucket_strict.txt b/cmd/pint/tests/0070_bitbucket_strict.txt index af2c3496..4cc36c69 100644 --- a/cmd/pint/tests/0070_bitbucket_strict.txt +++ b/cmd/pint/tests/0070_bitbucket_strict.txt @@ -1,4 +1,3 @@ -http response bitbucket /rest/insights/1.0/projects/prometheus/repos/rules/commits/.*/reports/pint 200 OK http response bitbucket /rest/api/1.0/projects/prometheus/repos/rules/commits/.*/pull-requests 200 {} http start bitbucket 127.0.0.1:6070 @@ -53,66 +52,8 @@ repository { } -- bitbucket.expected -- -DELETE /rest/insights/1.0/projects/prometheus/repos/rules/commits/.*/reports/pint - Accept-Encoding: gzip - Authorization: Bearer "12345" - Content-Type: application/json - -PUT /rest/insights/1.0/projects/prometheus/repos/rules/commits/.*/reports/pint - Accept-Encoding: gzip - Authorization: Bearer "12345" - Content-Type: application/json ---- BODY --- -reporter: Prometheus rule linter -title: pint unknown -result: FAIL -details: |- - pint is a Prometheus rule linter/validator. - It will inspect all Prometheus recording and alerting rules for problems that could prevent these from working correctly. - Checks can be either offline (static checks using only rule definition) or online (validate rule against live Prometheus server). -link: https://cloudflare.github.io/pint/ -data: - - value: 1 - title: Number of rules parsed - type: NUMBER - - value: 1 - title: Number of rules checked - type: NUMBER - - value: 1 - title: Number of problems found - type: NUMBER - - value: 1 - title: Number of offline checks - type: NUMBER - - value: 0 - title: Number of online checks - type: NUMBER - - value: 0 - title: Checks duration - type: DURATION ---- END --- - GET /rest/api/1.0/projects/prometheus/repos/rules/commits/.*/pull-requests Accept-Encoding: gzip Authorization: Bearer "12345" Content-Type: application/json -DELETE /rest/insights/1.0/projects/prometheus/repos/rules/commits/.*/reports/pint - Accept-Encoding: gzip - Authorization: Bearer "12345" - Content-Type: application/json - -POST /rest/insights/1.0/projects/prometheus/repos/rules/commits/.*/reports/pint - Accept-Encoding: gzip - Authorization: Bearer "12345" - Content-Type: application/json ---- BODY --- -annotations: - - path: rules.yml - message: "Problem reported on unmodified line 1, annotation moved here: yaml/parse: top level field must be a groups key, got list" - severity: HIGH - type: BUG - link: https://cloudflare.github.io/pint/checks/yaml/parse.html - line: 3 ---- END --- - diff --git a/cmd/pint/tests/0071_ci_owner.txt b/cmd/pint/tests/0071_ci_owner.txt index 6d04620a..10960209 100644 --- a/cmd/pint/tests/0071_ci_owner.txt +++ b/cmd/pint/tests/0071_ci_owner.txt @@ -1,4 +1,3 @@ -http response bitbucket /rest/insights/1.0/projects/prometheus/repos/rules/commits/.*/reports/pint 200 OK http response bitbucket /rest/api/1.0/projects/prometheus/repos/rules/commits/.*/pull-requests 200 {} http start bitbucket 127.0.0.1:6071 @@ -71,78 +70,8 @@ repository { } -- bitbucket.expected -- -DELETE /rest/insights/1.0/projects/prometheus/repos/rules/commits/.*/reports/pint - Accept-Encoding: gzip - Authorization: Bearer "12345" - Content-Type: application/json - -PUT /rest/insights/1.0/projects/prometheus/repos/rules/commits/.*/reports/pint - Accept-Encoding: gzip - Authorization: Bearer "12345" - Content-Type: application/json ---- BODY --- -reporter: Prometheus rule linter -title: pint unknown -result: FAIL -details: |- - pint is a Prometheus rule linter/validator. - It will inspect all Prometheus recording and alerting rules for problems that could prevent these from working correctly. - Checks can be either offline (static checks using only rule definition) or online (validate rule against live Prometheus server). -link: https://cloudflare.github.io/pint/ -data: - - value: 3 - title: Number of rules parsed - type: NUMBER - - value: 2 - title: Number of rules checked - type: NUMBER - - value: 3 - title: Number of problems found - type: NUMBER - - value: 18 - title: Number of offline checks - type: NUMBER - - value: 0 - title: Number of online checks - type: NUMBER - - value: 0 - title: Checks duration - type: DURATION ---- END --- - GET /rest/api/1.0/projects/prometheus/repos/rules/commits/.*/pull-requests Accept-Encoding: gzip Authorization: Bearer "12345" Content-Type: application/json -DELETE /rest/insights/1.0/projects/prometheus/repos/rules/commits/.*/reports/pint - Accept-Encoding: gzip - Authorization: Bearer "12345" - Content-Type: application/json - -POST /rest/insights/1.0/projects/prometheus/repos/rules/commits/.*/reports/pint - Accept-Encoding: gzip - Authorization: Bearer "12345" - Content-Type: application/json ---- BODY --- -annotations: - - path: rules.yml - message: "rule/owner: missing owner" - severity: MEDIUM - type: BUG - link: https://cloudflare.github.io/pint/checks/rule/owner.html - line: 5 - - path: rules.yml - message: "alerts/comparison: always firing alert" - severity: LOW - type: CODE_SMELL - link: https://cloudflare.github.io/pint/checks/alerts/comparison.html - line: 5 - - path: rules.yml - message: "rule/owner: missing owner" - severity: MEDIUM - type: BUG - link: https://cloudflare.github.io/pint/checks/rule/owner.html - line: 7 ---- END --- - diff --git a/cmd/pint/tests/0072_bitbucket_move_bug_to_modified.txt b/cmd/pint/tests/0072_bitbucket_move_bug_to_modified.txt index 79fb335b..28d98188 100644 --- a/cmd/pint/tests/0072_bitbucket_move_bug_to_modified.txt +++ b/cmd/pint/tests/0072_bitbucket_move_bug_to_modified.txt @@ -1,4 +1,3 @@ -http response bitbucket /rest/insights/1.0/projects/prometheus/repos/rules/commits/.*/reports/pint 200 OK http response bitbucket /rest/api/1.0/projects/prometheus/repos/rules/commits/.*/pull-requests 200 {} http start bitbucket 127.0.0.1:6072 @@ -54,66 +53,8 @@ repository { } -- bitbucket.expected -- -DELETE /rest/insights/1.0/projects/prometheus/repos/rules/commits/.*/reports/pint - Accept-Encoding: gzip - Authorization: Bearer "12345" - Content-Type: application/json - -PUT /rest/insights/1.0/projects/prometheus/repos/rules/commits/.*/reports/pint - Accept-Encoding: gzip - Authorization: Bearer "12345" - Content-Type: application/json ---- BODY --- -reporter: Prometheus rule linter -title: pint unknown -result: FAIL -details: |- - pint is a Prometheus rule linter/validator. - It will inspect all Prometheus recording and alerting rules for problems that could prevent these from working correctly. - Checks can be either offline (static checks using only rule definition) or online (validate rule against live Prometheus server). -link: https://cloudflare.github.io/pint/ -data: - - value: 1 - title: Number of rules parsed - type: NUMBER - - value: 1 - title: Number of rules checked - type: NUMBER - - value: 1 - title: Number of problems found - type: NUMBER - - value: 1 - title: Number of offline checks - type: NUMBER - - value: 0 - title: Number of online checks - type: NUMBER - - value: 0 - title: Checks duration - type: DURATION ---- END --- - GET /rest/api/1.0/projects/prometheus/repos/rules/commits/.*/pull-requests Accept-Encoding: gzip Authorization: Bearer "12345" Content-Type: application/json -DELETE /rest/insights/1.0/projects/prometheus/repos/rules/commits/.*/reports/pint - Accept-Encoding: gzip - Authorization: Bearer "12345" - Content-Type: application/json - -POST /rest/insights/1.0/projects/prometheus/repos/rules/commits/.*/reports/pint - Accept-Encoding: gzip - Authorization: Bearer "12345" - Content-Type: application/json ---- BODY --- -annotations: - - path: rules.yml - message: "Problem reported on unmodified line 2, annotation moved here: yaml/parse: did not find expected key" - severity: HIGH - type: BUG - link: https://cloudflare.github.io/pint/checks/yaml/parse.html - line: 3 ---- END --- - diff --git a/cmd/pint/tests/0075_ci_strict.txt b/cmd/pint/tests/0075_ci_strict.txt index 6661191f..c7474818 100644 --- a/cmd/pint/tests/0075_ci_strict.txt +++ b/cmd/pint/tests/0075_ci_strict.txt @@ -1,4 +1,3 @@ -http response bitbucket /rest/insights/1.0/projects/prometheus/repos/rules/commits/.*/reports/pint 200 OK http response bitbucket /rest/api/1.0/projects/prometheus/repos/rules/commits/.*/pull-requests 200 {} http start bitbucket 127.0.0.1:6075 @@ -51,72 +50,8 @@ repository { } -- bitbucket.expected -- -DELETE /rest/insights/1.0/projects/prometheus/repos/rules/commits/.*/reports/pint - Accept-Encoding: gzip - Authorization: Bearer "12345" - Content-Type: application/json - -PUT /rest/insights/1.0/projects/prometheus/repos/rules/commits/.*/reports/pint - Accept-Encoding: gzip - Authorization: Bearer "12345" - Content-Type: application/json ---- BODY --- -reporter: Prometheus rule linter -title: pint unknown -result: FAIL -details: |- - pint is a Prometheus rule linter/validator. - It will inspect all Prometheus recording and alerting rules for problems that could prevent these from working correctly. - Checks can be either offline (static checks using only rule definition) or online (validate rule against live Prometheus server). -link: https://cloudflare.github.io/pint/ -data: - - value: 1 - title: Number of rules parsed - type: NUMBER - - value: 1 - title: Number of rules checked - type: NUMBER - - value: 2 - title: Number of problems found - type: NUMBER - - value: 9 - title: Number of offline checks - type: NUMBER - - value: 0 - title: Number of online checks - type: NUMBER - - value: 0 - title: Checks duration - type: DURATION ---- END --- - GET /rest/api/1.0/projects/prometheus/repos/rules/commits/.*/pull-requests Accept-Encoding: gzip Authorization: Bearer "12345" Content-Type: application/json -DELETE /rest/insights/1.0/projects/prometheus/repos/rules/commits/.*/reports/pint - Accept-Encoding: gzip - Authorization: Bearer "12345" - Content-Type: application/json - -POST /rest/insights/1.0/projects/prometheus/repos/rules/commits/.*/reports/pint - Accept-Encoding: gzip - Authorization: Bearer "12345" - Content-Type: application/json ---- BODY --- -annotations: - - path: rules.yml - message: "rule/owner: missing owner" - severity: MEDIUM - type: BUG - link: https://cloudflare.github.io/pint/checks/rule/owner.html - line: 5 - - path: rules.yml - message: "promql/syntax: PromQL syntax error" - severity: HIGH - type: BUG - link: https://cloudflare.github.io/pint/checks/promql/syntax.html - line: 5 ---- END --- - diff --git a/cmd/pint/tests/0076_ci_group_errors.txt b/cmd/pint/tests/0076_ci_group_errors.txt index 55ccb8f7..412ac0fa 100644 --- a/cmd/pint/tests/0076_ci_group_errors.txt +++ b/cmd/pint/tests/0076_ci_group_errors.txt @@ -1,4 +1,3 @@ -http response bitbucket /rest/insights/1.0/projects/prometheus/repos/rules/commits/.*/reports/pint 200 OK http response bitbucket /rest/api/1.0/projects/prometheus/repos/rules/commits/.*/pull-requests 200 {} http start bitbucket 127.0.0.1:6076 @@ -210,338 +209,10 @@ Bug: required label is being removed via aggregation (promql/aggregate) ^^^ Query is using aggregation that removes all labels. `job` label is required and should be preserved when aggregating all rules. -level=ERROR msg="Execution completed with error(s)" err="submitting reports: fatal error(s) reported" +level=ERROR msg="Execution completed with error(s)" err="problems found" -- bitbucket.expected -- -DELETE /rest/insights/1.0/projects/prometheus/repos/rules/commits/.*/reports/pint - Accept-Encoding: gzip - Authorization: Bearer "12345" - Content-Type: application/json - -PUT /rest/insights/1.0/projects/prometheus/repos/rules/commits/.*/reports/pint - Accept-Encoding: gzip - Authorization: Bearer "12345" - Content-Type: application/json ---- BODY --- -reporter: Prometheus rule linter -title: pint unknown -result: FAIL -details: |- - pint is a Prometheus rule linter/validator. - It will inspect all Prometheus recording and alerting rules for problems that could prevent these from working correctly. - Checks can be either offline (static checks using only rule definition) or online (validate rule against live Prometheus server). -link: https://cloudflare.github.io/pint/ -data: - - value: 10 - title: Number of rules parsed - type: NUMBER - - value: 10 - title: Number of rules checked - type: NUMBER - - value: 46 - title: Number of problems found - type: NUMBER - - value: 116 - title: Number of offline checks - type: NUMBER - - value: 0 - title: Number of online checks - type: NUMBER - - value: 0 - title: Checks duration - type: DURATION ---- END --- - GET /rest/api/1.0/projects/prometheus/repos/rules/commits/.*/pull-requests Accept-Encoding: gzip Authorization: Bearer "12345" Content-Type: application/json -DELETE /rest/insights/1.0/projects/prometheus/repos/rules/commits/.*/reports/pint - Accept-Encoding: gzip - Authorization: Bearer "12345" - Content-Type: application/json - -POST /rest/insights/1.0/projects/prometheus/repos/rules/commits/.*/reports/pint - Accept-Encoding: gzip - Authorization: Bearer "12345" - Content-Type: application/json ---- BODY --- -annotations: - - path: rules.yml - message: "alerts/annotation: required annotation not set" - severity: MEDIUM - type: BUG - link: https://cloudflare.github.io/pint/checks/alerts/annotation.html - line: 5 - - path: rules.yml - message: "alerts/annotation: required annotation not set" - severity: MEDIUM - type: BUG - link: https://cloudflare.github.io/pint/checks/alerts/annotation.html - line: 5 - - path: rules.yml - message: "rule/label: required label not set" - severity: MEDIUM - type: BUG - link: https://cloudflare.github.io/pint/checks/rule/label.html - line: 5 - - path: rules.yml - message: "rule/label: required label not set" - severity: MEDIUM - type: BUG - link: https://cloudflare.github.io/pint/checks/rule/label.html - line: 5 - - path: rules.yml - message: "rule/label: required label not set" - severity: MEDIUM - type: BUG - link: https://cloudflare.github.io/pint/checks/rule/label.html - line: 5 - - path: rules.yml - message: "rule/owner: missing owner" - severity: MEDIUM - type: BUG - link: https://cloudflare.github.io/pint/checks/rule/owner.html - line: 5 - - path: rules.yml - message: "promql/syntax: PromQL syntax error" - severity: HIGH - type: BUG - link: https://cloudflare.github.io/pint/checks/promql/syntax.html - line: 5 - - path: rules.yml - message: "alerts/annotation: required annotation not set" - severity: MEDIUM - type: BUG - link: https://cloudflare.github.io/pint/checks/alerts/annotation.html - line: 8 - - path: rules.yml - message: "alerts/annotation: required annotation not set" - severity: MEDIUM - type: BUG - link: https://cloudflare.github.io/pint/checks/alerts/annotation.html - line: 8 - - path: rules.yml - message: "rule/label: required label not set" - severity: MEDIUM - type: BUG - link: https://cloudflare.github.io/pint/checks/rule/label.html - line: 8 - - path: rules.yml - message: "rule/label: required label not set" - severity: MEDIUM - type: BUG - link: https://cloudflare.github.io/pint/checks/rule/label.html - line: 8 - - path: rules.yml - message: "rule/label: required label not set" - severity: MEDIUM - type: BUG - link: https://cloudflare.github.io/pint/checks/rule/label.html - line: 8 - - path: rules.yml - message: "rule/owner: missing owner" - severity: MEDIUM - type: BUG - link: https://cloudflare.github.io/pint/checks/rule/owner.html - line: 8 - - path: rules.yml - message: "alerts/comparison: always firing alert" - severity: LOW - type: CODE_SMELL - link: https://cloudflare.github.io/pint/checks/alerts/comparison.html - line: 8 - - path: rules.yml - message: "rule/owner: missing owner" - severity: MEDIUM - type: BUG - link: https://cloudflare.github.io/pint/checks/rule/owner.html - line: 11 - - path: rules.yml - message: "alerts/annotation: required annotation not set" - severity: MEDIUM - type: BUG - link: https://cloudflare.github.io/pint/checks/alerts/annotation.html - line: 17 - - path: rules.yml - message: "rule/owner: missing owner" - severity: MEDIUM - type: BUG - link: https://cloudflare.github.io/pint/checks/rule/owner.html - line: 17 - - path: rules.yml - message: "alerts/annotation: required annotation not set" - severity: MEDIUM - type: BUG - link: https://cloudflare.github.io/pint/checks/alerts/annotation.html - line: 23 - - path: rules.yml - message: "rule/owner: missing owner" - severity: MEDIUM - type: BUG - link: https://cloudflare.github.io/pint/checks/rule/owner.html - line: 23 - - path: rules.yml - message: "alerts/comparison: always firing alert" - severity: LOW - type: CODE_SMELL - link: https://cloudflare.github.io/pint/checks/alerts/comparison.html - line: 20 - - path: rules.yml - message: "alerts/for: redundant field with default value" - severity: LOW - type: CODE_SMELL - link: https://cloudflare.github.io/pint/checks/alerts/for.html - line: 21 - - path: rules.yml - message: "alerts/annotation: required annotation not set" - severity: MEDIUM - type: BUG - link: https://cloudflare.github.io/pint/checks/alerts/annotation.html - line: 30 - - path: rules.yml - message: "alerts/annotation: required annotation not set" - severity: MEDIUM - type: BUG - link: https://cloudflare.github.io/pint/checks/alerts/annotation.html - line: 30 - - path: rules.yml - message: "alerts/template: template uses non-existent label" - severity: MEDIUM - type: BUG - link: https://cloudflare.github.io/pint/checks/alerts/template.html - line: 30 - - path: rules.yml - message: "rule/label: required label not set" - severity: MEDIUM - type: BUG - link: https://cloudflare.github.io/pint/checks/rule/label.html - line: 30 - - path: rules.yml - message: "rule/label: required label not set" - severity: MEDIUM - type: BUG - link: https://cloudflare.github.io/pint/checks/rule/label.html - line: 30 - - path: rules.yml - message: "rule/label: required label not set" - severity: MEDIUM - type: BUG - link: https://cloudflare.github.io/pint/checks/rule/label.html - line: 30 - - path: rules.yml - message: "rule/owner: missing owner" - severity: MEDIUM - type: BUG - link: https://cloudflare.github.io/pint/checks/rule/owner.html - line: 30 - - path: rules.yml - message: "alerts/template: value used in labels" - severity: MEDIUM - type: BUG - link: https://cloudflare.github.io/pint/checks/alerts/template.html - line: 28 - - path: rules.yml - message: "alerts/annotation: required annotation not set" - severity: MEDIUM - type: BUG - link: https://cloudflare.github.io/pint/checks/alerts/annotation.html - line: 33 - - path: rules.yml - message: "alerts/annotation: required annotation not set" - severity: MEDIUM - type: BUG - link: https://cloudflare.github.io/pint/checks/alerts/annotation.html - line: 33 - - path: rules.yml - message: "rule/label: required label not set" - severity: MEDIUM - type: BUG - link: https://cloudflare.github.io/pint/checks/rule/label.html - line: 33 - - path: rules.yml - message: "rule/label: required label not set" - severity: MEDIUM - type: BUG - link: https://cloudflare.github.io/pint/checks/rule/label.html - line: 33 - - path: rules.yml - message: "rule/label: required label not set" - severity: MEDIUM - type: BUG - link: https://cloudflare.github.io/pint/checks/rule/label.html - line: 33 - - path: rules.yml - message: "rule/owner: missing owner" - severity: MEDIUM - type: BUG - link: https://cloudflare.github.io/pint/checks/rule/owner.html - line: 33 - - path: rules.yml - message: "alerts/comparison: always firing alert" - severity: LOW - type: CODE_SMELL - link: https://cloudflare.github.io/pint/checks/alerts/comparison.html - line: 33 - - path: rules.yml - message: "rule/owner: missing owner" - severity: MEDIUM - type: BUG - link: https://cloudflare.github.io/pint/checks/rule/owner.html - line: 36 - - path: rules.yml - message: "promql/regexp: redundant regexp" - severity: LOW - type: CODE_SMELL - link: https://cloudflare.github.io/pint/checks/promql/regexp.html - line: 36 - - path: rules.yml - message: "promql/aggregate: required label is being removed via aggregation" - severity: MEDIUM - type: BUG - link: https://cloudflare.github.io/pint/checks/promql/aggregate.html - line: 36 - - path: rules.yml - message: "alerts/annotation: required annotation not set" - severity: MEDIUM - type: BUG - link: https://cloudflare.github.io/pint/checks/alerts/annotation.html - line: 39 - - path: rules.yml - message: "alerts/annotation: required annotation not set" - severity: MEDIUM - type: BUG - link: https://cloudflare.github.io/pint/checks/alerts/annotation.html - line: 39 - - path: rules.yml - message: "rule/label: required label not set" - severity: MEDIUM - type: BUG - link: https://cloudflare.github.io/pint/checks/rule/label.html - line: 39 - - path: rules.yml - message: "rule/label: required label not set" - severity: MEDIUM - type: BUG - link: https://cloudflare.github.io/pint/checks/rule/label.html - line: 39 - - path: rules.yml - message: "rule/label: required label not set" - severity: MEDIUM - type: BUG - link: https://cloudflare.github.io/pint/checks/rule/label.html - line: 39 - - path: rules.yml - message: "rule/owner: missing owner" - severity: MEDIUM - type: BUG - link: https://cloudflare.github.io/pint/checks/rule/owner.html - line: 39 - - path: rules.yml - message: "alerts/comparison: always firing alert" - severity: LOW - type: CODE_SMELL - link: https://cloudflare.github.io/pint/checks/alerts/comparison.html - line: 39 ---- END --- - diff --git a/cmd/pint/tests/0093_ci_bitbucket_ignore_file.txt b/cmd/pint/tests/0093_ci_bitbucket_ignore_file.txt index 8c92644f..a19f5611 100644 --- a/cmd/pint/tests/0093_ci_bitbucket_ignore_file.txt +++ b/cmd/pint/tests/0093_ci_bitbucket_ignore_file.txt @@ -1,4 +1,3 @@ -http response bitbucket /rest/insights/1.0/projects/prometheus/repos/rules/commits/.*/reports/pint 200 OK http response bitbucket /rest/api/1.0/projects/prometheus/repos/rules/commits/.*/pull-requests 200 {} http start bitbucket 127.0.0.1:6093 @@ -56,66 +55,8 @@ repository { } -- bitbucket.expected -- -DELETE /rest/insights/1.0/projects/prometheus/repos/rules/commits/.*/reports/pint - Accept-Encoding: gzip - Authorization: Bearer "12345" - Content-Type: application/json - -PUT /rest/insights/1.0/projects/prometheus/repos/rules/commits/.*/reports/pint - Accept-Encoding: gzip - Authorization: Bearer "12345" - Content-Type: application/json ---- BODY --- -reporter: Prometheus rule linter -title: pint unknown -result: PASS -details: |- - pint is a Prometheus rule linter/validator. - It will inspect all Prometheus recording and alerting rules for problems that could prevent these from working correctly. - Checks can be either offline (static checks using only rule definition) or online (validate rule against live Prometheus server). -link: https://cloudflare.github.io/pint/ -data: - - value: 1 - title: Number of rules parsed - type: NUMBER - - value: 1 - title: Number of rules checked - type: NUMBER - - value: 1 - title: Number of problems found - type: NUMBER - - value: 1 - title: Number of offline checks - type: NUMBER - - value: 0 - title: Number of online checks - type: NUMBER - - value: 0 - title: Checks duration - type: DURATION ---- END --- - GET /rest/api/1.0/projects/prometheus/repos/rules/commits/.*/pull-requests Accept-Encoding: gzip Authorization: Bearer "12345" Content-Type: application/json -DELETE /rest/insights/1.0/projects/prometheus/repos/rules/commits/.*/reports/pint - Accept-Encoding: gzip - Authorization: Bearer "12345" - Content-Type: application/json - -POST /rest/insights/1.0/projects/prometheus/repos/rules/commits/.*/reports/pint - Accept-Encoding: gzip - Authorization: Bearer "12345" - Content-Type: application/json ---- BODY --- -annotations: - - path: rules.yml - message: "Problem reported on unmodified line 1, annotation moved here: ignore/file: This file was excluded from pint checks." - severity: LOW - type: CODE_SMELL - link: https://cloudflare.github.io/pint/checks/ignore/file.html - line: 3 ---- END --- - diff --git a/cmd/pint/tests/0094_rule_file_symlink_bb.txt b/cmd/pint/tests/0094_rule_file_symlink_bb.txt index 4fcf5775..0627a73e 100644 --- a/cmd/pint/tests/0094_rule_file_symlink_bb.txt +++ b/cmd/pint/tests/0094_rule_file_symlink_bb.txt @@ -12,7 +12,6 @@ http response prometheus5m /api/v1/query_range 200 {"status":"success","data":{" http response prometheus5m /api/v1/query 200 {"status":"success","data":{"resultType":"vector","result":[]}} http start prometheus5m 127.0.0.1:2094 -http response bitbucket /rest/insights/1.0/projects/prometheus/repos/rules/commits/.*/reports/pint 200 OK http response bitbucket /rest/api/1.0/projects/prometheus/repos/rules/commits/.*/pull-requests 200 {} http start bitbucket 127.0.0.1:6094 @@ -49,7 +48,6 @@ stderr 'path=double.yml rule=rule2' ! stderr 'path=ignored.yml' stderr 'level=INFO msg="Problems found" Bug=4' -stderr 'result":"FAIL"' ! stderr '---> rules.yml:5 Bug: Duration for `rate' ! stderr '---> rules.yml:7 Bug: Duration for `rate' @@ -58,16 +56,6 @@ stderr '---> symlink.yml ~> rules.yml:7' stderr '---> double.yml ~> rules.yml:5' stderr '---> double.yml ~> rules.yml:7' -stderr '{"path":"rules.yml","message":"Problem detected on symlinked file symlink.yml: .*","line":5}' -stderr '{"path":"rules.yml","message":"Problem detected on symlinked file double.yml: .*","line":5}' -stderr '{"path":"rules.yml","message":"Problem detected on symlinked file symlink.yml: .*","line":7}' -stderr '{"path":"rules.yml","message":"Problem detected on symlinked file double.yml: .*","line":7}' -! stderr '{"path":"symlink.yml"' -! stderr '{"path":"double.yml"' - -stderr 'Problem detected on symlinked file symlink.yml:' -stderr 'Problem detected on symlinked file double.yml:' - cmp bitbucket.got ../bitbucket.expected -- src/v1.yml -- @@ -114,84 +102,8 @@ prometheus "5m" { } -- bitbucket.expected -- -DELETE /rest/insights/1.0/projects/prometheus/repos/rules/commits/.*/reports/pint - Accept-Encoding: gzip - Authorization: Bearer "12345" - Content-Type: application/json - -PUT /rest/insights/1.0/projects/prometheus/repos/rules/commits/.*/reports/pint - Accept-Encoding: gzip - Authorization: Bearer "12345" - Content-Type: application/json ---- BODY --- -reporter: Prometheus rule linter -title: pint unknown -result: FAIL -details: |- - pint is a Prometheus rule linter/validator. - It will inspect all Prometheus recording and alerting rules for problems that could prevent these from working correctly. - Checks can be either offline (static checks using only rule definition) or online (validate rule against live Prometheus server). -link: https://cloudflare.github.io/pint/ -data: - - value: 6 - title: Number of rules parsed - type: NUMBER - - value: 6 - title: Number of rules checked - type: NUMBER - - value: 4 - title: Number of problems found - type: NUMBER - - value: 60 - title: Number of offline checks - type: NUMBER - - value: 48 - title: Number of online checks - type: NUMBER - - value: 0 - title: Checks duration - type: DURATION ---- END --- - GET /rest/api/1.0/projects/prometheus/repos/rules/commits/.*/pull-requests Accept-Encoding: gzip Authorization: Bearer "12345" Content-Type: application/json -DELETE /rest/insights/1.0/projects/prometheus/repos/rules/commits/.*/reports/pint - Accept-Encoding: gzip - Authorization: Bearer "12345" - Content-Type: application/json - -POST /rest/insights/1.0/projects/prometheus/repos/rules/commits/.*/reports/pint - Accept-Encoding: gzip - Authorization: Bearer "12345" - Content-Type: application/json ---- BODY --- -annotations: - - path: rules.yml - message: "Problem detected on symlinked file double.yml: promql/rate: duration too small" - severity: MEDIUM - type: BUG - link: https://cloudflare.github.io/pint/checks/promql/rate.html - line: 5 - - path: rules.yml - message: "Problem detected on symlinked file double.yml: promql/rate: duration too small" - severity: MEDIUM - type: BUG - link: https://cloudflare.github.io/pint/checks/promql/rate.html - line: 7 - - path: rules.yml - message: "Problem detected on symlinked file symlink.yml: promql/rate: duration too small" - severity: MEDIUM - type: BUG - link: https://cloudflare.github.io/pint/checks/promql/rate.html - line: 5 - - path: rules.yml - message: "Problem detected on symlinked file symlink.yml: promql/rate: duration too small" - severity: MEDIUM - type: BUG - link: https://cloudflare.github.io/pint/checks/promql/rate.html - line: 7 ---- END --- - diff --git a/cmd/pint/tests/0100_ci_alerts_count.txt b/cmd/pint/tests/0100_ci_alerts_count.txt index e0c93b7a..ae12a0d6 100644 --- a/cmd/pint/tests/0100_ci_alerts_count.txt +++ b/cmd/pint/tests/0100_ci_alerts_count.txt @@ -5,7 +5,6 @@ http response prometheus /api/v1/query_range 200 {"status":"success","data":{"re http response prometheus /api/v1/query 200 {"status":"success","data":{"resultType":"vector","result":[]}} http start prometheus 127.0.0.1:2100 -http response bitbucket /rest/insights/1.0/projects/prometheus/repos/rules/commits/.*/reports/pint 200 OK http response bitbucket /rest/api/1.0/projects/prometheus/repos/rules/commits/.*/pull-requests 200 {} http start bitbucket 127.0.0.1:6100 @@ -82,72 +81,8 @@ rule { } -- bitbucket.expected -- -DELETE /rest/insights/1.0/projects/prometheus/repos/rules/commits/.*/reports/pint - Accept-Encoding: gzip - Authorization: Bearer "12345" - Content-Type: application/json - -PUT /rest/insights/1.0/projects/prometheus/repos/rules/commits/.*/reports/pint - Accept-Encoding: gzip - Authorization: Bearer "12345" - Content-Type: application/json ---- BODY --- -reporter: Prometheus rule linter -title: pint unknown -result: PASS -details: |- - pint is a Prometheus rule linter/validator. - It will inspect all Prometheus recording and alerting rules for problems that could prevent these from working correctly. - Checks can be either offline (static checks using only rule definition) or online (validate rule against live Prometheus server). -link: https://cloudflare.github.io/pint/ -data: - - value: 2 - title: Number of rules parsed - type: NUMBER - - value: 2 - title: Number of rules checked - type: NUMBER - - value: 2 - title: Number of problems found - type: NUMBER - - value: 20 - title: Number of offline checks - type: NUMBER - - value: 18 - title: Number of online checks - type: NUMBER - - value: 0 - title: Checks duration - type: DURATION ---- END --- - GET /rest/api/1.0/projects/prometheus/repos/rules/commits/.*/pull-requests Accept-Encoding: gzip Authorization: Bearer "12345" Content-Type: application/json -DELETE /rest/insights/1.0/projects/prometheus/repos/rules/commits/.*/reports/pint - Accept-Encoding: gzip - Authorization: Bearer "12345" - Content-Type: application/json - -POST /rest/insights/1.0/projects/prometheus/repos/rules/commits/.*/reports/pint - Accept-Encoding: gzip - Authorization: Bearer "12345" - Content-Type: application/json ---- BODY --- -annotations: - - path: rules.yml - message: "alerts/count: alert count estimate" - severity: LOW - type: CODE_SMELL - link: https://cloudflare.github.io/pint/checks/alerts/count.html - line: 4 - - path: rules.yml - message: "alerts/count: alert count estimate" - severity: LOW - type: CODE_SMELL - link: https://cloudflare.github.io/pint/checks/alerts/count.html - line: 11 ---- END --- - diff --git a/cmd/pint/tests/0123_ci_owner_allowed.txt b/cmd/pint/tests/0123_ci_owner_allowed.txt index 9b259f21..f65c9f3d 100644 --- a/cmd/pint/tests/0123_ci_owner_allowed.txt +++ b/cmd/pint/tests/0123_ci_owner_allowed.txt @@ -1,4 +1,3 @@ -http response bitbucket /rest/insights/1.0/projects/prometheus/repos/rules/commits/.*/reports/pint 200 OK http response bitbucket /rest/api/1.0/projects/prometheus/repos/rules/commits/.*/pull-requests 200 {} http start bitbucket 127.0.0.1:6123 @@ -82,72 +81,8 @@ repository { } -- bitbucket.expected -- -DELETE /rest/insights/1.0/projects/prometheus/repos/rules/commits/.*/reports/pint - Accept-Encoding: gzip - Authorization: Bearer "12345" - Content-Type: application/json - -PUT /rest/insights/1.0/projects/prometheus/repos/rules/commits/.*/reports/pint - Accept-Encoding: gzip - Authorization: Bearer "12345" - Content-Type: application/json ---- BODY --- -reporter: Prometheus rule linter -title: pint unknown -result: FAIL -details: |- - pint is a Prometheus rule linter/validator. - It will inspect all Prometheus recording and alerting rules for problems that could prevent these from working correctly. - Checks can be either offline (static checks using only rule definition) or online (validate rule against live Prometheus server). -link: https://cloudflare.github.io/pint/ -data: - - value: 6 - title: Number of rules parsed - type: NUMBER - - value: 6 - title: Number of rules checked - type: NUMBER - - value: 2 - title: Number of problems found - type: NUMBER - - value: 30 - title: Number of offline checks - type: NUMBER - - value: 0 - title: Number of online checks - type: NUMBER - - value: 0 - title: Checks duration - type: DURATION ---- END --- - GET /rest/api/1.0/projects/prometheus/repos/rules/commits/.*/pull-requests Accept-Encoding: gzip Authorization: Bearer "12345" Content-Type: application/json -DELETE /rest/insights/1.0/projects/prometheus/repos/rules/commits/.*/reports/pint - Accept-Encoding: gzip - Authorization: Bearer "12345" - Content-Type: application/json - -POST /rest/insights/1.0/projects/prometheus/repos/rules/commits/.*/reports/pint - Accept-Encoding: gzip - Authorization: Bearer "12345" - Content-Type: application/json ---- BODY --- -annotations: - - path: rules.yml - message: "rule/owner: missing owner" - severity: MEDIUM - type: BUG - link: https://cloudflare.github.io/pint/checks/rule/owner.html - line: 4 - - path: rules.yml - message: "rule/owner: invalid owner" - severity: MEDIUM - type: BUG - link: https://cloudflare.github.io/pint/checks/rule/owner.html - line: 7 ---- END --- - diff --git a/cmd/pint/tests/0163_ci_comment_resolve.txt b/cmd/pint/tests/0163_ci_comment_resolve.txt index 3514fa77..1200f7a2 100644 --- a/cmd/pint/tests/0163_ci_comment_resolve.txt +++ b/cmd/pint/tests/0163_ci_comment_resolve.txt @@ -1,5 +1,4 @@ http response bitbucket /plugins/servlet/applinks/whoami 200 pint -http response bitbucket /rest/insights/1.0/projects/prometheus/repos/rules/commits/.*/reports/pint 200 OK http response bitbucket /rest/api/1.0/projects/prometheus/repos/rules/commits/.*/pull-requests 200 {"size":1,"isLastPage":true,"values":[{"id":123,"open":true,"fromRef":{"id":"refs/heads/modify","latestCommit":"fake-commit-id"},"toRef":{"id":"refs/heads/main","latestCommit":"fake-commit-id"}}]} http response bitbucket /rest/api/1.0/projects/prometheus/repos/rules/pull-requests/123/changes 200 {"values":[{"path":{"toString":"rules.yml"}}],"size":1,"isLastPage":true} http response bitbucket /rest/api/latest/projects/prometheus/repos/rules/commits/fake-commit-id/diff/rules.yml 200 {"diffs":[{"hunks":[{"segments":[{"type":"ADDED", "lines":[{"source":5,"destination":5}]}]}]}]} @@ -29,8 +28,7 @@ exec git commit -am 'v1' stderr 'msg="Problems found" Fatal=1' stderr 'msg="Found open pull request, reporting problems using comments" id=123 srcBranch=modify srcCommit=fake-commit-id dstBranch=main dstCommit=fake-commit-id' stderr 'msg="Got existing pull request comments from BitBucket" count=1' -stderr 'msg="Generated comments to add to BitBucket" count=1' -stderr 'msg="Added pull request comments to BitBucket" count=1' +stderr 'msg="Creating a new comment" reporter=BitBucket path=rules.yml line=5' cp ../src/v2.yml rules.yml exec git commit -am 'v2' @@ -38,10 +36,7 @@ exec pint --no-color ci ! stdout . ! stderr 'msg="Problems found"' stderr 'msg="Found open pull request, reporting problems using comments" id=123 srcBranch=modify srcCommit=fake-commit-id dstBranch=main dstCommit=fake-commit-id' -stderr 'msg="Getting pull request changes from BitBucket"' stderr 'msg="Got existing pull request comments from BitBucket" count=1' -stderr 'msg="Generated comments to add to BitBucket" count=0' -stderr 'msg="Added pull request comments to BitBucket" count=0' cmp bitbucket.got ../bitbucket.expected -- src/v0.yml -- @@ -75,60 +70,11 @@ repository { } -- bitbucket.expected -- -DELETE /rest/insights/1.0/projects/prometheus/repos/rules/commits/.*/reports/pint - Accept-Encoding: gzip - Authorization: Bearer "12345" - Content-Type: application/json - -PUT /rest/insights/1.0/projects/prometheus/repos/rules/commits/.*/reports/pint - Accept-Encoding: gzip - Authorization: Bearer "12345" - Content-Type: application/json ---- BODY --- -reporter: Prometheus rule linter -title: pint unknown -result: FAIL -details: |- - pint is a Prometheus rule linter/validator. - It will inspect all Prometheus recording and alerting rules for problems that could prevent these from working correctly. - Checks can be either offline (static checks using only rule definition) or online (validate rule against live Prometheus server). -link: https://cloudflare.github.io/pint/ -data: - - value: 1 - title: Number of rules parsed - type: NUMBER - - value: 1 - title: Number of rules checked - type: NUMBER - - value: 1 - title: Number of problems found - type: NUMBER - - value: 9 - title: Number of offline checks - type: NUMBER - - value: 0 - title: Number of online checks - type: NUMBER - - value: 0 - title: Checks duration - type: DURATION ---- END --- - GET /rest/api/1.0/projects/prometheus/repos/rules/commits/.*/pull-requests Accept-Encoding: gzip Authorization: Bearer "12345" Content-Type: application/json -GET /rest/api/1.0/projects/prometheus/repos/rules/pull-requests/123/changes - Accept-Encoding: gzip - Authorization: Bearer "12345" - Content-Type: application/json - -GET /rest/api/latest/projects/prometheus/repos/rules/commits/fake-commit-id/diff/rules.yml - Accept-Encoding: gzip - Authorization: Bearer "12345" - Content-Type: application/json - GET /plugins/servlet/applinks/whoami Accept-Encoding: gzip Authorization: Bearer "12345" @@ -139,15 +85,6 @@ GET /rest/api/latest/projects/prometheus/repos/rules/pull-requests/123/activitie Authorization: Bearer "12345" Content-Type: application/json -PUT /rest/api/1.0/projects/prometheus/repos/rules/pull-requests/123/comments - Accept-Encoding: gzip - Authorization: Bearer "12345" - Content-Type: application/json ---- BODY --- -state: RESOLVED -version: 0 ---- END --- - POST /rest/api/1.0/projects/prometheus/repos/rules/pull-requests/123/comments Accept-Encoding: gzip Authorization: Bearer "12345" @@ -156,9 +93,8 @@ POST /rest/api/1.0/projects/prometheus/repos/rules/pull-requests/123/comments text: | :stop_sign: **Fatal** reported by [pint](https://cloudflare.github.io/pint/) **promql/syntax** check. - ------ - - PromQL syntax error +
+ PromQL syntax error ```yaml 5 | expr: count(up == 1) bie(job) @@ -169,6 +105,8 @@ text: | [Click here](https://prometheus.io/docs/prometheus/latest/querying/basics/) for PromQL documentation. +
+ ------ :information_source: To see documentation covering this check and instructions on how to resolve it [click here](https://cloudflare.github.io/pint/checks/promql/syntax.html). @@ -181,60 +119,16 @@ anchor: line: 5 --- END --- -DELETE /rest/insights/1.0/projects/prometheus/repos/rules/commits/.*/reports/pint +DELETE /rest/api/1.0/projects/prometheus/repos/rules/pull-requests/123/comments Accept-Encoding: gzip Authorization: Bearer "12345" Content-Type: application/json -PUT /rest/insights/1.0/projects/prometheus/repos/rules/commits/.*/reports/pint - Accept-Encoding: gzip - Authorization: Bearer "12345" - Content-Type: application/json ---- BODY --- -reporter: Prometheus rule linter -title: pint unknown -result: PASS -details: |- - pint is a Prometheus rule linter/validator. - It will inspect all Prometheus recording and alerting rules for problems that could prevent these from working correctly. - Checks can be either offline (static checks using only rule definition) or online (validate rule against live Prometheus server). -link: https://cloudflare.github.io/pint/ -data: - - value: 2 - title: Number of rules parsed - type: NUMBER - - value: 2 - title: Number of rules checked - type: NUMBER - - value: 0 - title: Number of problems found - type: NUMBER - - value: 9 - title: Number of offline checks - type: NUMBER - - value: 0 - title: Number of online checks - type: NUMBER - - value: 0 - title: Checks duration - type: DURATION ---- END --- - GET /rest/api/1.0/projects/prometheus/repos/rules/commits/.*/pull-requests Accept-Encoding: gzip Authorization: Bearer "12345" Content-Type: application/json -GET /rest/api/1.0/projects/prometheus/repos/rules/pull-requests/123/changes - Accept-Encoding: gzip - Authorization: Bearer "12345" - Content-Type: application/json - -GET /rest/api/latest/projects/prometheus/repos/rules/commits/fake-commit-id/diff/rules.yml - Accept-Encoding: gzip - Authorization: Bearer "12345" - Content-Type: application/json - GET /plugins/servlet/applinks/whoami Accept-Encoding: gzip Authorization: Bearer "12345" @@ -245,12 +139,8 @@ GET /rest/api/latest/projects/prometheus/repos/rules/pull-requests/123/activitie Authorization: Bearer "12345" Content-Type: application/json -PUT /rest/api/1.0/projects/prometheus/repos/rules/pull-requests/123/comments +DELETE /rest/api/1.0/projects/prometheus/repos/rules/pull-requests/123/comments Accept-Encoding: gzip Authorization: Bearer "12345" Content-Type: application/json ---- BODY --- -state: RESOLVED -version: 0 ---- END --- diff --git a/cmd/pint/tests/0164_ci_comment_resolve_delete.txt b/cmd/pint/tests/0164_ci_comment_resolve_delete.txt index 610efd5a..b1237c5b 100644 --- a/cmd/pint/tests/0164_ci_comment_resolve_delete.txt +++ b/cmd/pint/tests/0164_ci_comment_resolve_delete.txt @@ -1,5 +1,4 @@ http response bitbucket /plugins/servlet/applinks/whoami 200 pint -http response bitbucket /rest/insights/1.0/projects/prometheus/repos/rules/commits/.*/reports/pint 200 OK http response bitbucket /rest/api/1.0/projects/prometheus/repos/rules/commits/.*/pull-requests 200 {"size":1,"isLastPage":true,"values":[{"id":123,"open":true,"fromRef":{"id":"refs/heads/modify","latestCommit":"fake-commit-id"},"toRef":{"id":"refs/heads/main","latestCommit":"fake-commit-id"}}]} http response bitbucket /rest/api/1.0/projects/prometheus/repos/rules/pull-requests/123/changes 200 {"values":[{"path":{"toString":"rules.yml"}}],"size":1,"isLastPage":true} http response bitbucket /rest/api/latest/projects/prometheus/repos/rules/commits/fake-commit-id/diff/rules.yml 200 {"diffs":[{"hunks":[{"segments":[{"type":"ADDED", "lines":[{"source":5,"destination":5}]}]}]}]} @@ -29,8 +28,7 @@ exec git commit -am 'v1' stderr 'msg="Problems found" Fatal=1' stderr 'msg="Found open pull request, reporting problems using comments" id=123 srcBranch=modify srcCommit=fake-commit-id dstBranch=main dstCommit=fake-commit-id' stderr 'msg="Got existing pull request comments from BitBucket" count=1' -stderr 'msg="Generated comments to add to BitBucket" count=1' -stderr 'msg="Added pull request comments to BitBucket" count=1' +stderr 'msg="Creating a new comment" reporter=BitBucket path=rules.yml line=5' cmp bitbucket.got ../bitbucket.expected cp ../src/v2.yml rules.yml @@ -39,10 +37,7 @@ exec pint --no-color ci ! stdout . ! stderr 'msg="Problems found"' stderr 'msg="Found open pull request, reporting problems using comments" id=123 srcBranch=modify srcCommit=fake-commit-id dstBranch=main dstCommit=fake-commit-id' -stderr 'msg="Getting pull request changes from BitBucket"' stderr 'msg="Got existing pull request comments from BitBucket" count=1' -stderr 'msg="Generated comments to add to BitBucket" count=0' -stderr 'msg="Added pull request comments to BitBucket" count=0' -- src/v0.yml -- groups: @@ -75,60 +70,11 @@ repository { } -- bitbucket.expected -- -DELETE /rest/insights/1.0/projects/prometheus/repos/rules/commits/.*/reports/pint - Accept-Encoding: gzip - Authorization: Bearer "12345" - Content-Type: application/json - -PUT /rest/insights/1.0/projects/prometheus/repos/rules/commits/.*/reports/pint - Accept-Encoding: gzip - Authorization: Bearer "12345" - Content-Type: application/json ---- BODY --- -reporter: Prometheus rule linter -title: pint unknown -result: FAIL -details: |- - pint is a Prometheus rule linter/validator. - It will inspect all Prometheus recording and alerting rules for problems that could prevent these from working correctly. - Checks can be either offline (static checks using only rule definition) or online (validate rule against live Prometheus server). -link: https://cloudflare.github.io/pint/ -data: - - value: 1 - title: Number of rules parsed - type: NUMBER - - value: 1 - title: Number of rules checked - type: NUMBER - - value: 1 - title: Number of problems found - type: NUMBER - - value: 9 - title: Number of offline checks - type: NUMBER - - value: 0 - title: Number of online checks - type: NUMBER - - value: 0 - title: Checks duration - type: DURATION ---- END --- - GET /rest/api/1.0/projects/prometheus/repos/rules/commits/.*/pull-requests Accept-Encoding: gzip Authorization: Bearer "12345" Content-Type: application/json -GET /rest/api/1.0/projects/prometheus/repos/rules/pull-requests/123/changes - Accept-Encoding: gzip - Authorization: Bearer "12345" - Content-Type: application/json - -GET /rest/api/latest/projects/prometheus/repos/rules/commits/fake-commit-id/diff/rules.yml - Accept-Encoding: gzip - Authorization: Bearer "12345" - Content-Type: application/json - GET /plugins/servlet/applinks/whoami Accept-Encoding: gzip Authorization: Bearer "12345" @@ -139,24 +85,6 @@ GET /rest/api/latest/projects/prometheus/repos/rules/pull-requests/123/activitie Authorization: Bearer "12345" Content-Type: application/json -PUT /rest/api/1.0/projects/prometheus/repos/rules/pull-requests/123/comments - Accept-Encoding: gzip - Authorization: Bearer "12345" - Content-Type: application/json ---- BODY --- -severity: BLOCKER -version: 0 ---- END --- - -PUT /rest/api/1.0/projects/prometheus/repos/rules/pull-requests/123/comments - Accept-Encoding: gzip - Authorization: Bearer "12345" - Content-Type: application/json ---- BODY --- -state: RESOLVED -version: 0 ---- END --- - POST /rest/api/1.0/projects/prometheus/repos/rules/pull-requests/123/comments Accept-Encoding: gzip Authorization: Bearer "12345" @@ -165,9 +93,8 @@ POST /rest/api/1.0/projects/prometheus/repos/rules/pull-requests/123/comments text: | :stop_sign: **Fatal** reported by [pint](https://cloudflare.github.io/pint/) **promql/syntax** check. - ------ - - PromQL syntax error +
+ PromQL syntax error ```yaml 5 | expr: count(up == 1) bie(job) @@ -178,6 +105,8 @@ text: | [Click here](https://prometheus.io/docs/prometheus/latest/querying/basics/) for PromQL documentation. +
+ ------ :information_source: To see documentation covering this check and instructions on how to resolve it [click here](https://cloudflare.github.io/pint/checks/promql/syntax.html). @@ -190,3 +119,8 @@ anchor: line: 5 --- END --- +DELETE /rest/api/1.0/projects/prometheus/repos/rules/pull-requests/123/comments + Accept-Encoding: gzip + Authorization: Bearer "12345" + Content-Type: application/json + diff --git a/cmd/pint/tests/0188_ci_noop.txt b/cmd/pint/tests/0188_ci_noop.txt index a76583ec..b540a816 100644 --- a/cmd/pint/tests/0188_ci_noop.txt +++ b/cmd/pint/tests/0188_ci_noop.txt @@ -1,4 +1,3 @@ -http response bitbucket /rest/insights/1.0/projects/prometheus/repos/rules/commits/.*/reports/pint 200 OK http response bitbucket /rest/api/1.0/projects/prometheus/repos/rules/commits/.*/pull-requests 200 {} http start bitbucket 127.0.0.1:6188 @@ -64,52 +63,8 @@ rule { } -- bitbucket.expected -- -DELETE /rest/insights/1.0/projects/prometheus/repos/rules/commits/.*/reports/pint - Accept-Encoding: gzip - Authorization: Bearer "12345" - Content-Type: application/json - -PUT /rest/insights/1.0/projects/prometheus/repos/rules/commits/.*/reports/pint - Accept-Encoding: gzip - Authorization: Bearer "12345" - Content-Type: application/json ---- BODY --- -reporter: Prometheus rule linter -title: pint unknown -result: PASS -details: |- - pint is a Prometheus rule linter/validator. - It will inspect all Prometheus recording and alerting rules for problems that could prevent these from working correctly. - Checks can be either offline (static checks using only rule definition) or online (validate rule against live Prometheus server). -link: https://cloudflare.github.io/pint/ -data: - - value: 2 - title: Number of rules parsed - type: NUMBER - - value: 2 - title: Number of rules checked - type: NUMBER - - value: 0 - title: Number of problems found - type: NUMBER - - value: 0 - title: Number of offline checks - type: NUMBER - - value: 0 - title: Number of online checks - type: NUMBER - - value: 0 - title: Checks duration - type: DURATION ---- END --- - GET /rest/api/1.0/projects/prometheus/repos/rules/commits/.*/pull-requests Accept-Encoding: gzip Authorization: Bearer "12345" Content-Type: application/json -DELETE /rest/insights/1.0/projects/prometheus/repos/rules/commits/.*/reports/pint - Accept-Encoding: gzip - Authorization: Bearer "12345" - Content-Type: application/json - diff --git a/docs/changelog.md b/docs/changelog.md index ea467c87..5b33420e 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -6,6 +6,8 @@ - When running `pint ci` pint will now use `git diff` to calculate modified lines instead of `git blame`. +- BitBucket reporter no longer creates Code Insight reports and annotations. + All problems are now reported exclusively via pull request comments. ## v0.80.0 diff --git a/internal/reporter/bitbucket.go b/internal/reporter/bitbucket.go index 2d4dec41..f747d91f 100644 --- a/internal/reporter/bitbucket.go +++ b/internal/reporter/bitbucket.go @@ -1,164 +1,478 @@ package reporter import ( + "bytes" "context" - "errors" + "encoding/json" "fmt" + "io" "log/slog" + "net/http" + "strings" "time" + "github.com/cloudflare/pint/internal/checks" "github.com/cloudflare/pint/internal/git" "github.com/cloudflare/pint/internal/output" ) -const ( - BitBucketDescription = "pint is a Prometheus rule linter/validator.\n" + - "It will inspect all Prometheus recording and alerting rules for problems that could prevent these from working correctly.\n" + - "Checks can be either offline (static checks using only rule definition) or online (validate rule against live Prometheus server)." -) +type BitBucketRef struct { + ID string `json:"id"` + Commit string `json:"latestCommit"` +} -func NewBitBucketReporter(version, uri string, timeout time.Duration, token, project, repo string, maxComments int, showDuplicates bool, gitCmd git.CommandRunner) BitBucketReporter { - slog.LogAttrs(context.Background(), slog.LevelInfo, - "Will report problems to BitBucket", - slog.String("uri", uri), - slog.String("timeout", output.HumanizeDuration(timeout)), - slog.String("project", project), - slog.String("repo", repo), - slog.Int("maxComments", maxComments), - ) - return BitBucketReporter{ - api: newBitBucketAPI(version, uri, timeout, token, project, repo, maxComments, showDuplicates), - gitCmd: gitCmd, +type BitBucketPullRequest struct { + FromRef BitBucketRef `json:"fromRef"` + ToRef BitBucketRef `json:"toRef"` + ID int `json:"id"` + Open bool `json:"open"` +} + +type BitBucketPullRequests struct { + Values []BitBucketPullRequest `json:"values"` + Start int `json:"start"` + NextPageStart int `json:"nextPageStart"` + IsLastPage bool `json:"isLastPage"` +} + +type bitBucketPR struct { + srcBranch string + srcHead string + dstBranch string + dstHead string + ID int +} + +type bitBucketCommentMeta struct { + id int + version int +} + +type bitBucketComment struct { + text string + anchor BitBucketCommentAnchor + id int + version int +} + +type BitBucketCommentAuthor struct { + Name string `json:"name"` +} + +type BitBucketPullRequestComment struct { + State string `json:"state"` + Author BitBucketCommentAuthor `json:"author"` + Text string `json:"text"` + Severity string `json:"severity"` + Comments []BitBucketPullRequestComment `json:"comments"` + ID int `json:"id"` + Version int `json:"version"` + Resolved bool `json:"threadResolved"` +} + +type BitBucketCommentAnchor struct { + LineType string `json:"lineType"` + DiffType string `json:"diffType"` + Path string `json:"path"` + Line int `json:"line"` + Orphaned bool `json:"orphaned"` +} + +type BitBucketPullRequestActivity struct { + Action string `json:"action"` + CommentAction string `json:"commentAction"` + CommentAnchor BitBucketCommentAnchor `json:"commentAnchor"` + Comment BitBucketPullRequestComment `json:"comment"` +} + +type BitBucketPullRequestActivities struct { + Values []BitBucketPullRequestActivity `json:"values"` + Start int `json:"start"` + NextPageStart int `json:"nextPageStart"` + IsLastPage bool `json:"isLastPage"` +} + +type bitBucketPendingCommentAnchor struct { + Path string `json:"path,omitempty"` + LineType string `json:"lineType,omitempty"` + FileType string `json:"fileType,omitempty"` + DiffType string `json:"diffType"` + Line int `json:"line,omitempty"` +} + +type BitBucketPendingComment struct { + Text string `json:"text"` + Severity string `json:"severity"` + Anchor bitBucketPendingCommentAnchor `json:"anchor"` +} + +func newBitBucketAPI(uri string, timeout time.Duration, token, project, repo string) *bitBucketAPI { + return &bitBucketAPI{ + uri: uri, + timeout: timeout, + authToken: token, + project: project, + repo: repo, } } -// BitBucketReporter send linter results to BitBucket using -// https://docs.atlassian.com/bitbucket-server/rest/7.8.0/bitbucket-code-insights-rest.html -type BitBucketReporter struct { - api *bitBucketAPI - gitCmd git.CommandRunner +type bitBucketAPI struct { + uri string + authToken string + project string + repo string + timeout time.Duration } -func (bb BitBucketReporter) Submit(ctx context.Context, summary Summary) (err error) { - var headCommit string - if headCommit, err = git.HeadCommit(bb.gitCmd); err != nil { - return fmt.Errorf("failed to get HEAD commit: %w", err) +func (bb bitBucketAPI) request(method, path string, body io.Reader) ([]byte, error) { + slog.LogAttrs(context.Background(), slog.LevelInfo, "Sending a request to BitBucket", slog.String("method", method), slog.String("path", path)) + + if body != nil { + payload, _ := io.ReadAll(body) + slog.LogAttrs(context.Background(), slog.LevelDebug, "Request payload", slog.String("body", string(payload))) + body = bytes.NewReader(payload) } - slog.LogAttrs(ctx, slog.LevelInfo, "Got HEAD commit from git", slog.String("commit", headCommit)) - if err = bb.api.deleteReport(headCommit); err != nil { - slog.LogAttrs(ctx, slog.LevelError, "Failed to delete old BitBucket report", slog.Any("err", err)) + req, err := http.NewRequest(method, bb.uri+path, body) + if err != nil { + return nil, err } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+bb.authToken) - if err = bb.api.createReport(summary, headCommit); err != nil { - return fmt.Errorf("failed to create BitBucket report: %w", err) + netClient := &http.Client{ + Timeout: bb.timeout, } - var headBranch string - if headBranch, err = git.CurrentBranch(bb.gitCmd); err != nil { - return fmt.Errorf("failed to get current branch: %w", err) + resp, err := netClient.Do(req) + if err != nil { + return nil, err } + defer resp.Body.Close() - var pr *bitBucketPR - if pr, err = bb.api.findPullRequestForBranch(headBranch, headCommit); err != nil { - return fmt.Errorf("failed to get open pull requests from BitBucket: %w", err) + data, err := io.ReadAll(resp.Body) + if err != nil { + return data, err } - if pr != nil { - slog.LogAttrs(ctx, slog.LevelInfo, - "Found open pull request, reporting problems using comments", - slog.Int("id", pr.ID), - slog.String("srcBranch", pr.srcBranch), - slog.String("srcCommit", pr.srcHead), - slog.String("dstBranch", pr.dstBranch), - slog.String("dstCommit", pr.dstHead), + slog.LogAttrs(context.Background(), slog.LevelInfo, "BitBucket request completed", slog.Int("status", resp.StatusCode)) + slog.LogAttrs(context.Background(), slog.LevelDebug, "BitBucket response body", slog.Int("code", resp.StatusCode), slog.String("body", string(data))) + if resp.StatusCode >= 300 { + slog.LogAttrs(context.Background(), slog.LevelError, + "Got a non 2xx response", + slog.String("body", string(data)), + slog.String("path", path), + slog.Int("code", resp.StatusCode), ) + return data, fmt.Errorf("%s request failed", method) + } + + return data, err +} + +func (bb bitBucketAPI) whoami() (string, error) { + resp, err := bb.request(http.MethodGet, "/plugins/servlet/applinks/whoami", nil) + if err != nil { + return "", err + } + return strings.TrimSuffix(string(resp), "\n"), nil +} - slog.LogAttrs(ctx, slog.LevelInfo, "Getting pull request changes from BitBucket") - var changes *bitBucketPRChanges - if changes, err = bb.api.getPullRequestChanges(pr); err != nil { - return fmt.Errorf("failed to get pull request changes from BitBucket: %w", err) +func (bb bitBucketAPI) findPullRequestForBranch(branch, commit string) (*bitBucketPR, error) { + var start int + for { + resp, err := bb.request( + http.MethodGet, + fmt.Sprintf("/rest/api/1.0/projects/%s/repos/%s/commits/%s/pull-requests?start=%d", bb.project, bb.repo, commit, start), + nil, + ) + if err != nil { + return nil, err } - slog.LogAttrs(ctx, slog.LevelDebug, "Got modified files from BitBucket", slog.Any("files", changes.pathModifiedLines)) - var existingComments []bitBucketComment - if existingComments, err = bb.api.getPullRequestComments(pr); err != nil { - return fmt.Errorf("failed to get pull request comments from BitBucket: %w", err) + var prs BitBucketPullRequests + if err = json.Unmarshal(resp, &prs); err != nil { + return nil, err } - slog.LogAttrs(ctx, slog.LevelInfo, "Got existing pull request comments from BitBucket", slog.Int("count", len(existingComments))) - pendingComments := bb.api.makeComments(summary, changes) - slog.LogAttrs(ctx, slog.LevelInfo, "Generated comments to add to BitBucket", slog.Int("count", len(pendingComments))) + for _, pr := range prs.Values { + if !pr.Open { + continue + } + srcBranch := strings.TrimPrefix(pr.FromRef.ID, "refs/heads/") + dstBranch := strings.TrimPrefix(pr.ToRef.ID, "refs/heads/") + if srcBranch == branch { + return &bitBucketPR{ + ID: pr.ID, + srcBranch: srcBranch, + srcHead: pr.FromRef.Commit, + dstBranch: dstBranch, + dstHead: pr.ToRef.Commit, + }, nil + } + } - pendingComments = bb.api.limitComments(pendingComments) - slog.LogAttrs(ctx, slog.LevelInfo, "Will add comments to BitBucket", - slog.Int("count", len(pendingComments)), - slog.Int("limit", bb.api.maxComments), + if prs.IsLastPage || prs.NextPageStart == start { + break + } + start = prs.NextPageStart + } + + return nil, nil +} + +func (bb bitBucketAPI) getPullRequestComments(pr *bitBucketPR) ([]bitBucketComment, error) { + username, err := bb.whoami() + if err != nil { + return nil, err + } + + comments := []bitBucketComment{} + + var start int + for { + resp, err := bb.request( + http.MethodGet, + fmt.Sprintf( + "/rest/api/latest/projects/%s/repos/%s/pull-requests/%d/activities?start=%d", + bb.project, bb.repo, + pr.ID, + start, + ), + nil, ) + if err != nil { + return nil, err + } - slog.LogAttrs(ctx, slog.LevelInfo, "Deleting stale comments from BitBucket") - bb.api.pruneComments(pr, existingComments, pendingComments) + var acts BitBucketPullRequestActivities + if err = json.Unmarshal(resp, &acts); err != nil { + return nil, err + } - slog.LogAttrs(ctx, slog.LevelInfo, "Adding missing comments to BitBucket") - if err = bb.api.addComments(pr, existingComments, pendingComments); err != nil { - return fmt.Errorf("failed to create BitBucket pull request comments: %w", err) + for _, act := range acts.Values { + if act.Action != "COMMENTED" { + continue + } + if act.CommentAction != "ADDED" { + continue + } + if act.Comment.State != "OPEN" { + continue + } + if act.Comment.Author.Name != username { + continue + } + if act.Comment.Severity == "BLOCKER" && act.Comment.Resolved { + continue + } + if act.Comment.Severity == "NORMAL" && act.CommentAnchor.Orphaned { + continue + } + comments = append(comments, bitBucketComment{ + id: act.Comment.ID, + version: act.Comment.Version, + text: act.Comment.Text, + anchor: act.CommentAnchor, + }) } - } else { + if acts.IsLastPage || acts.NextPageStart == start { + break + } + start = acts.NextPageStart + } + + return comments, nil +} + +func (bb bitBucketAPI) createComment(pr *bitBucketPR, comment BitBucketPendingComment) error { + payload, _ := json.Marshal(comment) + _, err := bb.request( + http.MethodPost, + fmt.Sprintf( + "/rest/api/1.0/projects/%s/repos/%s/pull-requests/%d/comments", + bb.project, bb.repo, pr.ID, + ), + bytes.NewReader(payload), + ) + return err +} + +func (bb bitBucketAPI) deleteComment(pr *bitBucketPR, commentID, version int) error { + _, err := bb.request( + http.MethodDelete, + fmt.Sprintf( + "/rest/api/1.0/projects/%s/repos/%s/pull-requests/%d/comments/%d?version=%d", + bb.project, bb.repo, pr.ID, commentID, version, + ), + nil, + ) + return err +} + +func NewBitBucketReporter(uri string, timeout time.Duration, token, project, repo string, maxComments int, gitCmd git.CommandRunner) BitBucketReporter { + slog.LogAttrs(context.Background(), slog.LevelInfo, + "Will report problems to BitBucket", + slog.String("uri", uri), + slog.String("timeout", output.HumanizeDuration(timeout)), + slog.String("project", project), + slog.String("repo", repo), + slog.Int("maxComments", maxComments), + ) + return BitBucketReporter{ + api: newBitBucketAPI(uri, timeout, token, project, repo), + gitCmd: gitCmd, + maxComments: maxComments, + } +} + +type BitBucketReporter struct { + api *bitBucketAPI + gitCmd git.CommandRunner + maxComments int +} + +func (bb BitBucketReporter) Describe() string { + return "BitBucket" +} + +func (bb BitBucketReporter) Destinations(ctx context.Context) ([]any, error) { + headCommit, err := git.HeadCommit(bb.gitCmd) + if err != nil { + return nil, fmt.Errorf("failed to get HEAD commit: %w", err) + } + slog.LogAttrs(ctx, slog.LevelInfo, "Got HEAD commit from git", slog.String("commit", headCommit)) + + headBranch, err := git.CurrentBranch(bb.gitCmd) + if err != nil { + return nil, fmt.Errorf("failed to get current branch: %w", err) + } + + pr, err := bb.api.findPullRequestForBranch(headBranch, headCommit) + if err != nil { + return nil, fmt.Errorf("failed to get open pull requests from BitBucket: %w", err) + } + + if pr == nil { slog.LogAttrs(ctx, slog.LevelInfo, - "No open pull request found, reporting problems using code insight annotations", + "No open pull request found", slog.String("branch", headBranch), slog.String("commit", headCommit), ) + return nil, nil + } - if err = bb.api.deleteAnnotations(headCommit); err != nil { - return fmt.Errorf("failed to delete existing BitBucket code insight annotations: %w", err) - } + slog.LogAttrs(ctx, slog.LevelInfo, + "Found open pull request, reporting problems using comments", + slog.Int("id", pr.ID), + slog.String("srcBranch", pr.srcBranch), + slog.String("srcCommit", pr.srcHead), + slog.String("dstBranch", pr.dstBranch), + slog.String("dstCommit", pr.dstHead), + ) - if err = bb.api.createAnnotations(summary, headCommit); err != nil { - return fmt.Errorf("failed to create BitBucket code insight annotations: %w", err) - } - } + return []any{pr}, nil +} + +func (bb BitBucketReporter) Summary(_ context.Context, _ any, _ Summary, _ []PendingComment, _ []error) error { + return nil +} - if summary.HasFatalProblems() { - return errors.New("fatal error(s) reported") +func (bb BitBucketReporter) List(ctx context.Context, dst any) ([]ExistingComment, error) { + pr := dst.(*bitBucketPR) + comments, err := bb.api.getPullRequestComments(pr) + if err != nil { + return nil, err } + slog.LogAttrs(ctx, slog.LevelInfo, "Got existing pull request comments from BitBucket", slog.Int("count", len(comments))) - return nil + existing := make([]ExistingComment, 0, len(comments)) + for _, c := range comments { + existing = append(existing, ExistingComment{ + path: c.anchor.Path, + line: c.anchor.Line, + text: c.text, + meta: bitBucketCommentMeta{ + id: c.id, + version: c.version, + }, + }) + } + return existing, nil } -// BitBucket only allows us to report annotations for modified lines. -// If a high severity problem is detected on a non-modified line we move that annotation -// to the first modified line. -// Without this we could have a report that is marked as failed, but with no annotations -// at all, which would make it more difficult to fix. -// If we can't find any modified line to match with our report then we return 0, -// which will create a file level annotation. -func moveReportedLine(report Report) (reported, original int) { - reported = -1 - original = -1 - // No changes data, nothing to move. - if report.Changes == nil { - return report.Problem.Lines.Last, report.Problem.Lines.Last - } - for pl := report.Problem.Lines.First; pl <= report.Problem.Lines.Last; pl++ { - if original < 0 { - original = pl - } - if report.Changes.Lines.HasAfter(pl) { - original = pl - reported = pl - } +func (bb BitBucketReporter) Create(ctx context.Context, dst any, p PendingComment) error { + pr := dst.(*bitBucketPR) + + anchor := bitBucketPendingCommentAnchor{ + Path: p.path, + Line: p.line, + DiffType: "EFFECTIVE", + LineType: "CONTEXT", + FileType: "FROM", + } + + if p.anchor == checks.AnchorBefore { + anchor.LineType = "REMOVED" + } else if p.changedLines.HasAfter(p.line) { + anchor.LineType = "ADDED" + anchor.FileType = "TO" } - if reported < 0 { - if bestLine := report.Changes.Lines.NearestAfter(report.Problem.Lines.First); bestLine > 0 { - return bestLine, original + if anchor.FileType == "FROM" && p.anchor != checks.AnchorBefore { + if before := p.changedLines.BeforeForAfter(p.line); before != p.line { + anchor.Line = before } } - if reported < 0 { - reported = 0 + var severity string + if strings.HasPrefix(p.text, ":stop_sign:") { + severity = "BLOCKER" + } else { + severity = "NORMAL" + } + + slog.LogAttrs(ctx, slog.LevelDebug, "Creating BitBucket comment", + slog.String("path", anchor.Path), + slog.Int("line", anchor.Line), + slog.String("lineType", anchor.LineType), + slog.String("fileType", anchor.FileType), + slog.String("severity", severity), + ) + + return bb.api.createComment(pr, BitBucketPendingComment{ + Text: p.text, + Severity: severity, + Anchor: anchor, + }) +} + +func (bb BitBucketReporter) Delete(ctx context.Context, dst any, e ExistingComment) error { + pr := dst.(*bitBucketPR) + meta := e.meta.(bitBucketCommentMeta) + slog.LogAttrs(ctx, slog.LevelDebug, "Deleting BitBucket comment", + slog.Int("id", meta.id), + slog.String("path", e.path), + slog.Int("line", e.line), + ) + return bb.api.deleteComment(pr, meta.id, meta.version) +} + +func (bb BitBucketReporter) CanCreate(done int) bool { + return done < bb.maxComments +} + +func (bb BitBucketReporter) CanDelete(ExistingComment) bool { + return true +} + +func (bb BitBucketReporter) IsEqual(_ any, existing ExistingComment, pending PendingComment) bool { + if existing.path != pending.path { + return false + } + if existing.line != pending.line { + return false } - return reported, original + return strings.Trim(existing.text, "\n") == strings.Trim(pending.text, "\n") } diff --git a/internal/reporter/bitbucket_api.go b/internal/reporter/bitbucket_api.go deleted file mode 100644 index dc35b62d..00000000 --- a/internal/reporter/bitbucket_api.go +++ /dev/null @@ -1,917 +0,0 @@ -package reporter - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "log/slog" - "net/http" - "slices" - "strings" - "time" - - "github.com/cloudflare/pint/internal/checks" - "github.com/cloudflare/pint/internal/diags" - "github.com/cloudflare/pint/internal/output" -) - -type BitBucketReport struct { - Reporter string `json:"reporter"` - Title string `json:"title"` - Result string `json:"result"` - Details string `json:"details"` - Link string `json:"link"` - Data []BitBucketReportData `json:"data"` -} - -type DataType string - -const ( - BooleanType DataType = "BOOLEAN" - DateType DataType = "DATA" - DurationType DataType = "DURATION" - LinkType DataType = "LINK" - NumberType DataType = "NUMBER" - PercentageType DataType = "PERCENTAGE" - TextType DataType = "TEXT" - - maxCommentLength = 32768 -) - -type BitBucketReportData struct { - Value any `json:"value"` - Title string `json:"title"` - Type DataType `json:"type"` -} - -type BitBucketAnnotation struct { - Path string `json:"path"` - Message string `json:"message"` - Severity string `json:"severity"` - Type string `json:"type"` - Link string `json:"link"` - Line int `json:"line"` -} - -type BitBucketAnnotations struct { - Annotations []BitBucketAnnotation `json:"annotations"` -} - -type BitBucketRef struct { - ID string `json:"id"` - Commit string `json:"latestCommit"` -} - -type BitBucketPullRequest struct { - FromRef BitBucketRef `json:"fromRef"` - ToRef BitBucketRef `json:"toRef"` - ID int `json:"id"` - Open bool `json:"open"` -} - -type BitBucketPullRequests struct { - Values []BitBucketPullRequest `json:"values"` - Start int `json:"start"` - NextPageStart int `json:"nextPageStart"` - IsLastPage bool `json:"isLastPage"` -} - -type bitBucketPR struct { - srcBranch string - srcHead string - dstBranch string - dstHead string - ID int -} - -type bitBucketPRChanges struct { - pathModifiedLines map[string][]int - pathLineMapping map[string]map[int]int -} - -type BitBucketPath struct { - ToString string `json:"toString"` -} - -type BitBucketPullRequestChange struct { - Path BitBucketPath `json:"path"` -} - -type BitBucketPullRequestChanges struct { - Values []BitBucketPullRequestChange `json:"values"` - Start int `json:"start"` - NextPageStart int `json:"nextPageStart"` - IsLastPage bool `json:"isLastPage"` -} - -type BitBucketDiffLine struct { - Source int `json:"source"` - Destination int `json:"destination"` -} - -type BitBucketDiffSegment struct { - Type string `json:"type"` - Lines []BitBucketDiffLine `json:"lines"` -} - -type BitBucketDiffHunk struct { - Segments []BitBucketDiffSegment `json:"segments"` -} - -type BitBucketFileDiff struct { - Hunks []BitBucketDiffHunk `json:"hunks"` -} - -type BitBucketFileDiffs struct { - Diffs []BitBucketFileDiff `json:"diffs"` -} - -type bitBucketComment struct { - text string - severity string - anchor BitBucketCommentAnchor - id int - version int - replies int -} - -type BitBucketCommentAuthor struct { - Name string `json:"name"` -} - -type BitBucketPullRequestComment struct { - State string `json:"state"` - Author BitBucketCommentAuthor `json:"author"` - Text string `json:"text"` - Severity string `json:"severity"` - Comments []BitBucketPullRequestComment `json:"comments"` - ID int `json:"id"` - Version int `json:"version"` - Resolved bool `json:"threadResolved"` -} - -type BitBucketCommentAnchor struct { - LineType string `json:"lineType"` - DiffType string `json:"diffType"` - Path string `json:"path"` - Line int `json:"line"` - Orphaned bool `json:"orphaned"` -} - -func (ba BitBucketCommentAnchor) isEqual(pa BitBucketPendingCommentAnchor) bool { - if ba.Path != pa.Path { - return false - } - if ba.Line != pa.Line { - return false - } - if ba.LineType != pa.LineType { - return false - } - if ba.DiffType != pa.DiffType { - return false - } - return true -} - -type BitBucketPullRequestActivity struct { - Action string `json:"action"` - CommentAction string `json:"commentAction"` - CommentAnchor BitBucketCommentAnchor `json:"commentAnchor"` - Comment BitBucketPullRequestComment `json:"comment"` -} - -type BitBucketPullRequestActivities struct { - Values []BitBucketPullRequestActivity `json:"values"` - Start int `json:"start"` - NextPageStart int `json:"nextPageStart"` - IsLastPage bool `json:"isLastPage"` -} - -type pendingComment struct { - severity string - text string - path string - line int - anchor checks.Anchor -} - -func (pc pendingComment) toBitBucketComment(changes *bitBucketPRChanges) BitBucketPendingComment { - c := BitBucketPendingComment{ - Anchor: BitBucketPendingCommentAnchor{ - Path: pc.path, - Line: pc.line, - DiffType: "EFFECTIVE", - LineType: "CONTEXT", - FileType: "FROM", - }, - Text: pc.text, - Severity: pc.severity, - } - - if pc.anchor == checks.AnchorBefore { - c.Anchor.LineType = "REMOVED" - } else if changes != nil { - if lines, ok := changes.pathModifiedLines[pc.path]; ok && slices.Contains(lines, pc.line) { - c.Anchor.LineType = "ADDED" - c.Anchor.FileType = "TO" - } - if c.Anchor.FileType == "FROM" { - if m, ok := changes.pathLineMapping[pc.path]; ok { - if v, found := m[pc.line]; found { - c.Anchor.Line = v - } - } - } - } - - return c -} - -type BitBucketPendingCommentAnchor struct { - Path string `json:"path,omitempty"` - LineType string `json:"lineType,omitempty"` - FileType string `json:"fileType,omitempty"` - DiffType string `json:"diffType"` - Line int `json:"line,omitempty"` -} - -type BitBucketPendingComment struct { - Text string `json:"text"` - Severity string `json:"severity"` - Anchor BitBucketPendingCommentAnchor `json:"anchor"` -} - -type BitBucketCommentStateUpdate struct { - State string `json:"state"` - Version int `json:"version"` -} - -type BitBucketCommentSeverityUpdate struct { - Severity string `json:"severity"` - Version int `json:"version"` -} - -func newBitBucketAPI(pintVersion, uri string, timeout time.Duration, token, project, repo string, maxComments int, showDuplicates bool) *bitBucketAPI { - return &bitBucketAPI{ - pintVersion: pintVersion, - uri: uri, - timeout: timeout, - authToken: token, - project: project, - repo: repo, - maxComments: maxComments, - showDuplicates: showDuplicates, - } -} - -type bitBucketAPI struct { - pintVersion string - uri string - authToken string - project string - repo string - timeout time.Duration - maxComments int - showDuplicates bool -} - -func (bb bitBucketAPI) request(method, path string, body io.Reader) ([]byte, error) { - slog.LogAttrs(context.Background(), slog.LevelInfo, "Sending a request to BitBucket", slog.String("method", method), slog.String("path", path)) - - if body != nil { - payload, _ := io.ReadAll(body) - slog.LogAttrs(context.Background(), slog.LevelDebug, "Request payload", slog.String("body", string(payload))) - body = bytes.NewReader(payload) - } - - req, err := http.NewRequest(method, bb.uri+path, body) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+bb.authToken) - - netClient := &http.Client{ - Timeout: bb.timeout, - } - - resp, err := netClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - data, err := io.ReadAll(resp.Body) - if err != nil { - return data, err - } - - slog.LogAttrs(context.Background(), slog.LevelInfo, "BitBucket request completed", slog.Int("status", resp.StatusCode)) - slog.LogAttrs(context.Background(), slog.LevelDebug, "BitBucket response body", slog.Int("code", resp.StatusCode), slog.String("body", string(data))) - if resp.StatusCode >= 300 { - slog.LogAttrs(context.Background(), slog.LevelError, - "Got a non 2xx response", - slog.String("body", string(data)), - slog.String("path", path), - slog.Int("code", resp.StatusCode), - ) - return data, fmt.Errorf("%s request failed", method) - } - - return data, err -} - -func (bb bitBucketAPI) whoami() (string, error) { - resp, err := bb.request(http.MethodGet, "/plugins/servlet/applinks/whoami", nil) - if err != nil { - return "", err - } - return strings.TrimSuffix(string(resp), "\n"), nil -} - -func (bb bitBucketAPI) deleteReport(commit string) error { - _, err := bb.request( - http.MethodDelete, - fmt.Sprintf("/rest/insights/1.0/projects/%s/repos/%s/commits/%s/reports/pint", bb.project, bb.repo, commit), - nil, - ) - return err -} - -func (bb bitBucketAPI) createReport(summary Summary, commit string) error { - result := "PASS" - var reportedProblems int - for _, report := range summary.reports { - reportedProblems++ - if report.Problem.Severity >= checks.Bug { - result = "FAIL" - } - } - - payload, _ := json.Marshal(BitBucketReport{ - Title: "pint " + bb.pintVersion, - Result: result, - Reporter: "Prometheus rule linter", - Details: BitBucketDescription, - Link: "https://cloudflare.github.io/pint/", - Data: []BitBucketReportData{ - {Title: "Number of rules parsed", Type: NumberType, Value: summary.TotalEntries}, - {Title: "Number of rules checked", Type: NumberType, Value: summary.CheckedEntries}, - {Title: "Number of problems found", Type: NumberType, Value: reportedProblems}, - {Title: "Number of offline checks", Type: NumberType, Value: summary.OfflineChecks}, - {Title: "Number of online checks", Type: NumberType, Value: summary.OnlineChecks}, - {Title: "Checks duration", Type: DurationType, Value: summary.Duration.Milliseconds()}, - }, - }) - - _, err := bb.request( - http.MethodPut, - fmt.Sprintf("/rest/insights/1.0/projects/%s/repos/%s/commits/%s/reports/pint", bb.project, bb.repo, commit), - bytes.NewReader(payload), - ) - return err -} - -func (bb bitBucketAPI) createAnnotations(summary Summary, commit string) error { - annotations := make([]BitBucketAnnotation, 0, len(summary.reports)) - for _, report := range summary.reports { - ann := reportToAnnotation(report) - if !report.Changes.Lines.HasAfter(ann.Line) { - slog.LogAttrs(context.Background(), slog.LevelWarn, "Annotation for unmodified line, skipping", slog.String("path", ann.Path), slog.Int("line", ann.Line)) - continue - } - annotations = append(annotations, ann) - } - - if len(annotations) == 0 { - return nil - } - - payload, _ := json.Marshal(BitBucketAnnotations{Annotations: annotations}) - _, err := bb.request( - http.MethodPost, - fmt.Sprintf("/rest/insights/1.0/projects/%s/repos/%s/commits/%s/reports/pint/annotations", bb.project, bb.repo, commit), - bytes.NewReader(payload), - ) - return err -} - -func (bb bitBucketAPI) deleteAnnotations(commit string) error { - _, err := bb.request( - http.MethodDelete, - fmt.Sprintf("/rest/insights/1.0/projects/%s/repos/%s/commits/%s/reports/pint/annotations", bb.project, bb.repo, commit), - nil, - ) - return err -} - -func (bb bitBucketAPI) findPullRequestForBranch(branch, commit string) (*bitBucketPR, error) { - var start int - for { - resp, err := bb.request( - http.MethodGet, - fmt.Sprintf("/rest/api/1.0/projects/%s/repos/%s/commits/%s/pull-requests?start=%d", bb.project, bb.repo, commit, start), - nil, - ) - if err != nil { - return nil, err - } - - var prs BitBucketPullRequests - if err = json.Unmarshal(resp, &prs); err != nil { - return nil, err - } - - for _, pr := range prs.Values { - if !pr.Open { - continue - } - srcBranch := strings.TrimPrefix(pr.FromRef.ID, "refs/heads/") - dstBranch := strings.TrimPrefix(pr.ToRef.ID, "refs/heads/") - if srcBranch == branch { - return &bitBucketPR{ - ID: pr.ID, - srcBranch: srcBranch, - srcHead: pr.FromRef.Commit, - dstBranch: dstBranch, - dstHead: pr.ToRef.Commit, - }, nil - } - } - - if prs.IsLastPage || prs.NextPageStart == start { - break - } - start = prs.NextPageStart - } - - return nil, nil -} - -func (bb bitBucketAPI) getPullRequestChanges(pr *bitBucketPR) (*bitBucketPRChanges, error) { - prChanges := bitBucketPRChanges{ - pathModifiedLines: map[string][]int{}, - pathLineMapping: map[string]map[int]int{}, - } - - var start int - for { - resp, err := bb.request( - http.MethodGet, - fmt.Sprintf("/rest/api/1.0/projects/%s/repos/%s/pull-requests/%d/changes?start=%d", bb.project, bb.repo, pr.ID, start), - nil, - ) - if err != nil { - return nil, err - } - - var changes BitBucketPullRequestChanges - if err = json.Unmarshal(resp, &changes); err != nil { - return nil, err - } - - for _, ch := range changes.Values { - modifiedLines, lineMap, err := bb.getFileDiff(pr, ch.Path.ToString) - if err != nil { - return nil, err - } - prChanges.pathModifiedLines[ch.Path.ToString] = modifiedLines - prChanges.pathLineMapping[ch.Path.ToString] = lineMap - } - - if changes.IsLastPage || changes.NextPageStart == start { - break - } - start = changes.NextPageStart - } - - return &prChanges, nil -} - -func (bb bitBucketAPI) getFileDiff(pr *bitBucketPR, path string) ([]int, map[int]int, error) { - resp, err := bb.request( - http.MethodGet, - fmt.Sprintf( - "/rest/api/latest/projects/%s/repos/%s/commits/%s/diff/%s?contextLines=10000&since=%s&whitespace=show&withComments=false", - bb.project, bb.repo, - pr.srcHead, - path, - pr.dstHead, - ), - nil, - ) - if err != nil { - return nil, nil, err - } - - var fileDiffs BitBucketFileDiffs - if err = json.Unmarshal(resp, &fileDiffs); err != nil { - return nil, nil, err - } - - modifiedLines := []int{} - lineMap := map[int]int{} - for _, diff := range fileDiffs.Diffs { - for _, hunk := range diff.Hunks { - for _, seg := range hunk.Segments { - for _, line := range seg.Lines { - if seg.Type == "ADDED" { - modifiedLines = append(modifiedLines, line.Destination) - } - if seg.Type == "CONTEXT" || seg.Type == "ADDED" { - lineMap[line.Destination] = line.Source - } - } - } - } - } - - return modifiedLines, lineMap, nil -} - -func (bb bitBucketAPI) getPullRequestComments(pr *bitBucketPR) ([]bitBucketComment, error) { - username, err := bb.whoami() - if err != nil { - return nil, err - } - - comments := []bitBucketComment{} - - var start int - for { - resp, err := bb.request( - http.MethodGet, - fmt.Sprintf( - "/rest/api/latest/projects/%s/repos/%s/pull-requests/%d/activities?start=%d", - bb.project, bb.repo, - pr.ID, - start, - ), - nil, - ) - if err != nil { - return nil, err - } - - var acts BitBucketPullRequestActivities - if err = json.Unmarshal(resp, &acts); err != nil { - return nil, err - } - - for _, act := range acts.Values { - if act.Action != "COMMENTED" { - continue - } - if act.CommentAction != "ADDED" { - continue - } - if act.Comment.State != "OPEN" { - continue - } - if act.Comment.Author.Name != username { - continue - } - if act.Comment.Severity == "BLOCKER" && act.Comment.Resolved { - continue - } - if act.Comment.Severity == "NORMAL" && act.CommentAnchor.Orphaned { - continue - } - comments = append(comments, bitBucketComment{ - id: act.Comment.ID, - version: act.Comment.Version, - text: act.Comment.Text, - anchor: act.CommentAnchor, - severity: act.Comment.Severity, - replies: len(act.Comment.Comments), - }) - } - - if acts.IsLastPage || acts.NextPageStart == start { - break - } - start = acts.NextPageStart - } - - return comments, nil -} - -func (bb bitBucketAPI) makeComments(summary Summary, changes *bitBucketPRChanges) []BitBucketPendingComment { - var buf strings.Builder - var content string - var err error - comments := []BitBucketPendingComment{} - for _, reports := range dedupReports(summary.reports, bb.showDuplicates) { - if _, ok := changes.pathModifiedLines[reports[0].Path.SymlinkTarget]; !ok { - continue - } - - if reports[0].Problem.Anchor == checks.AnchorAfter { - content, err = readFile(reports[0].Path.Name) - if err != nil { - content = "" - } - } - - mergeDetails := identicalDetails(reports) - - buf.Reset() - - buf.WriteString(problemIcon(reports[0].Problem.Severity)) - buf.WriteString(" **") - buf.WriteString(reports[0].Problem.Severity.String()) - buf.WriteString("** reported by [pint](https://cloudflare.github.io/pint/) **") - buf.WriteString(reports[0].Problem.Reporter) - buf.WriteString("** check.\n\n") - for _, report := range reports { - buf.WriteString("------\n\n") - buf.WriteString(report.Problem.Summary) - buf.WriteString("\n\n") - if len(report.Problem.Diagnostics) > 0 && content != "" { - for _, diag := range report.Problem.Diagnostics { - buf.WriteString("```yaml\n") - buf.WriteString(diags.InjectDiagnostics( - content, - []diags.Diagnostic{ - { - Message: "", - Pos: diag.Pos, - FirstColumn: diag.FirstColumn, - LastColumn: diag.LastColumn, - Kind: diag.Kind, - }, - }, - output.None, - )) - buf.WriteString("```\n\n") - buf.WriteString(diag.Message) - buf.WriteString("\n\n") - } - } - if !mergeDetails && report.Problem.Details != "" { - buf.WriteString(report.Problem.Details) - buf.WriteString("\n\n") - } - if report.Path.SymlinkTarget != report.Path.Name { - buf.WriteString(":leftwards_arrow_with_hook: This problem was detected on a symlinked file ") - buf.WriteRune('`') - buf.WriteString(report.Path.Name) - buf.WriteString("`.\n\n") - } - } - if mergeDetails && reports[0].Problem.Details != "" { - buf.WriteString("------\n\n") - buf.WriteString(reports[0].Problem.Details) - buf.WriteString("\n\n") - } - buf.WriteString("------\n\n") - buf.WriteString(":information_source: To see documentation covering this check and instructions on how to resolve it [click here](https://cloudflare.github.io/pint/checks/") - buf.WriteString(reports[0].Problem.Reporter) - buf.WriteString(".html).\n") - - var severity string - // nolint:exhaustive - switch reports[0].Problem.Severity { - case checks.Bug, checks.Fatal: - severity = "BLOCKER" - default: - severity = "NORMAL" - } - - var text string - // BitBucket has a max comment length limit. If we hit it then truncate the comment. - if buf.Len() > maxCommentLength { - text = buf.String()[:maxCommentLength-4] + " ..." - } else { - text = buf.String() - } - - pending := pendingComment{ - severity: severity, - path: reports[0].Path.SymlinkTarget, - line: reports[0].Problem.Lines.Last, - text: text, - anchor: reports[0].Problem.Anchor, - } - comments = append(comments, pending.toBitBucketComment(changes)) - } - return comments -} - -func (bb bitBucketAPI) limitComments(src []BitBucketPendingComment) []BitBucketPendingComment { - if len(src) <= bb.maxComments { - return src - } - comments := src[:bb.maxComments] - comments = append(comments, BitBucketPendingComment{ - Text: fmt.Sprintf(`This pint run would create %d comment(s), which is more than %d limit configured for pint. -%d comments were skipped and won't be visible on this PR.`, len(src), bb.maxComments, len(src)-bb.maxComments), - Severity: "NORMAL", - Anchor: BitBucketPendingCommentAnchor{ // nolint: exhaustruct - DiffType: "EFFECTIVE", - }, - }) - return comments -} - -func (bb bitBucketAPI) pruneComments(pr *bitBucketPR, currentComments []bitBucketComment, pendingComments []BitBucketPendingComment) { - for _, cur := range currentComments { - slog.LogAttrs(context.Background(), slog.LevelDebug, - "Existing comment", - slog.Int("id", cur.id), - slog.Int("version", cur.version), - slog.String("severity", cur.severity), - slog.String("path", cur.anchor.Path), - slog.Int("line", cur.anchor.Line), - slog.String("diffType", cur.anchor.DiffType), - slog.String("lineType", cur.anchor.LineType), - slog.Bool("orphaned", cur.anchor.Orphaned), - slog.Int("replies", cur.replies), - ) - var keep bool - for _, pend := range pendingComments { - if cur.anchor.isEqual(pend.Anchor) && cur.text == pend.Text { - keep = true - break - } - if cur.anchor.DiffType == "COMMIT" { - keep = false - } - } - if !keep { - switch { - case cur.replies == 0: - bb.deleteComment(pr, cur) - case cur.severity == "BLOCKER": - bb.resolveTask(pr, cur) - default: - bb.updateSeverity(pr, cur, "BLOCKER") - bb.resolveTask(pr, cur) - } - } - } -} - -func (bb bitBucketAPI) deleteComment(pr *bitBucketPR, cur bitBucketComment) { - slog.LogAttrs(context.Background(), slog.LevelDebug, - "Deleting stale comment", - slog.Int("id", cur.id), - slog.String("path", cur.anchor.Path), - slog.Int("line", cur.anchor.Line), - ) - _, err := bb.request( - http.MethodDelete, - fmt.Sprintf("/rest/api/1.0/projects/%s/repos/%s/pull-requests/%d/comments/%d?version=%d", - bb.project, bb.repo, - pr.ID, - cur.id, cur.version, - ), - nil, - ) - if err != nil { - slog.LogAttrs(context.Background(), slog.LevelError, - "Failed to delete stale BitBucket pull request comment", - slog.Int("id", cur.id), - slog.Any("err", err), - ) - } -} - -func (bb bitBucketAPI) resolveTask(pr *bitBucketPR, cur bitBucketComment) { - slog.LogAttrs(context.Background(), slog.LevelDebug, - "Resolving stale blocker comment", - slog.Int("id", cur.id), - slog.String("path", cur.anchor.Path), - slog.Int("line", cur.anchor.Line), - ) - payload, _ := json.Marshal(BitBucketCommentStateUpdate{ - State: "RESOLVED", - Version: cur.version, - }) - _, err := bb.request( - http.MethodPut, - fmt.Sprintf("/rest/api/1.0/projects/%s/repos/%s/pull-requests/%d/comments/%d", - bb.project, bb.repo, - pr.ID, - cur.id, - ), - bytes.NewReader(payload), - ) - if err != nil { - slog.LogAttrs(context.Background(), slog.LevelError, - "Failed to resolve stale blocker BitBucket pull request comment", - slog.Int("id", cur.id), - slog.Any("err", err), - ) - } -} - -func (bb bitBucketAPI) updateSeverity(pr *bitBucketPR, cur bitBucketComment, severity string) { - slog.LogAttrs(context.Background(), slog.LevelDebug, - "Updating comment severity", - slog.Int("id", cur.id), - slog.String("path", cur.anchor.Path), - slog.Int("line", cur.anchor.Line), - slog.String("from", cur.severity), - slog.String("to", severity), - ) - payload, _ := json.Marshal(BitBucketCommentSeverityUpdate{ - Severity: severity, - Version: cur.version, - }) - _, err := bb.request( - http.MethodPut, - fmt.Sprintf("/rest/api/1.0/projects/%s/repos/%s/pull-requests/%d/comments/%d", - bb.project, bb.repo, - pr.ID, - cur.id, - ), - bytes.NewReader(payload), - ) - if err != nil { - slog.LogAttrs(context.Background(), slog.LevelError, - "Failed to update BitBucket pull request comment severity", - slog.Int("id", cur.id), - slog.Any("err", err), - ) - } -} - -func (bb bitBucketAPI) addComments(pr *bitBucketPR, currentComments []bitBucketComment, pendingComments []BitBucketPendingComment) error { - var added int - for _, pend := range pendingComments { - add := true - for _, cur := range currentComments { - if cur.anchor.isEqual(pend.Anchor) && cur.text == pend.Text { - add = false - } - if cur.anchor.DiffType == "COMMIT" { - add = true - } - } - if add { - slog.LogAttrs(context.Background(), slog.LevelDebug, - "Adding missing comment", - slog.String("path", pend.Anchor.Path), - slog.Int("line", pend.Anchor.Line), - slog.String("diffType", pend.Anchor.DiffType), - slog.String("lineType", pend.Anchor.LineType), - slog.String("fileType", pend.Anchor.FileType), - ) - payload, _ := json.Marshal(pend) - _, err := bb.request( - http.MethodPost, - fmt.Sprintf("/rest/api/1.0/projects/%s/repos/%s/pull-requests/%d/comments", - bb.project, bb.repo, - pr.ID, - ), - bytes.NewReader(payload), - ) - if err != nil { - return err - } - added++ - } - } - slog.LogAttrs(context.Background(), slog.LevelInfo, "Added pull request comments to BitBucket", slog.Int("count", added)) - return nil -} - -func reportToAnnotation(report Report) BitBucketAnnotation { - var msgPrefix, severity, atype string - reportLine, srcLine := moveReportedLine(report) - if reportLine != srcLine { - msgPrefix = fmt.Sprintf("Problem reported on unmodified line %d, annotation moved here: ", srcLine) - } - if report.Path.SymlinkTarget != report.Path.Name { - if msgPrefix == "" { - msgPrefix = fmt.Sprintf("Problem detected on symlinked file %s: ", report.Path.Name) - } else { - msgPrefix = fmt.Sprintf("Problem detected on symlinked file %s. %s", report.Path.Name, msgPrefix) - } - } - - switch report.Problem.Severity { - case checks.Fatal: - severity = "HIGH" - atype = "BUG" - case checks.Bug: - severity = "MEDIUM" - atype = "BUG" - case checks.Warning, checks.Information: - severity = "LOW" - atype = "CODE_SMELL" - } - - return BitBucketAnnotation{ - Path: report.Path.SymlinkTarget, - Line: reportLine, - Message: fmt.Sprintf("%s%s: %s", msgPrefix, report.Problem.Reporter, report.Problem.Summary), - Severity: severity, - Type: atype, - Link: fmt.Sprintf("https://cloudflare.github.io/pint/checks/%s.html", report.Problem.Reporter), - } -} diff --git a/internal/reporter/bitbucket_api_test.go b/internal/reporter/bitbucket_api_test.go deleted file mode 100644 index bb6d4ab6..00000000 --- a/internal/reporter/bitbucket_api_test.go +++ /dev/null @@ -1,1300 +0,0 @@ -package reporter - -import ( - "bytes" - "encoding/json" - "log/slog" - "net/http" - "testing" - "time" - - "github.com/neilotoole/slogt" - "github.com/stretchr/testify/require" - "go.nhat.io/httpmock" - - "github.com/cloudflare/pint/internal/checks" - "github.com/cloudflare/pint/internal/diags" - "github.com/cloudflare/pint/internal/discovery" - "github.com/cloudflare/pint/internal/git" -) - -func TestBitBucketCommentAnchorIsEqual(t *testing.T) { - type testCaseT struct { - description string - pending BitBucketPendingCommentAnchor - anchor BitBucketCommentAnchor - expected bool - } - - testCases := []testCaseT{ - { - description: "all fields match", - anchor: BitBucketCommentAnchor{ - Path: "foo.yaml", - Line: 10, - LineType: "ADDED", - DiffType: "EFFECTIVE", - }, - pending: BitBucketPendingCommentAnchor{ - Path: "foo.yaml", - Line: 10, - LineType: "ADDED", - DiffType: "EFFECTIVE", - }, - expected: true, - }, - { - description: "path mismatch", - anchor: BitBucketCommentAnchor{ - Path: "foo.yaml", - Line: 10, - LineType: "ADDED", - DiffType: "EFFECTIVE", - }, - pending: BitBucketPendingCommentAnchor{ - Path: "bar.yaml", - Line: 10, - LineType: "ADDED", - DiffType: "EFFECTIVE", - }, - expected: false, - }, - { - description: "line mismatch", - anchor: BitBucketCommentAnchor{ - Path: "foo.yaml", - Line: 10, - LineType: "ADDED", - DiffType: "EFFECTIVE", - }, - pending: BitBucketPendingCommentAnchor{ - Path: "foo.yaml", - Line: 20, - LineType: "ADDED", - DiffType: "EFFECTIVE", - }, - expected: false, - }, - { - description: "lineType mismatch", - anchor: BitBucketCommentAnchor{ - Path: "foo.yaml", - Line: 10, - LineType: "ADDED", - DiffType: "EFFECTIVE", - }, - pending: BitBucketPendingCommentAnchor{ - Path: "foo.yaml", - Line: 10, - LineType: "CONTEXT", - DiffType: "EFFECTIVE", - }, - expected: false, - }, - { - description: "diffType mismatch", - anchor: BitBucketCommentAnchor{ - Path: "foo.yaml", - Line: 10, - LineType: "ADDED", - DiffType: "EFFECTIVE", - }, - pending: BitBucketPendingCommentAnchor{ - Path: "foo.yaml", - Line: 10, - LineType: "ADDED", - DiffType: "RANGE", - }, - expected: false, - }, - } - - for _, tc := range testCases { - t.Run(tc.description, func(t *testing.T) { - result := tc.anchor.isEqual(tc.pending) - require.Equal(t, tc.expected, result) - }) - } -} - -func TestPendingCommentToBitBucketComment(t *testing.T) { - type testCaseT struct { - changes *bitBucketPRChanges - description string - output BitBucketPendingComment - input pendingComment - } - - testCases := []testCaseT{ - { - description: "nil changes", - input: pendingComment{ - severity: "NORMAL", - text: "this is text", - path: "foo.yaml", - line: 5, - }, - output: BitBucketPendingComment{ - Text: "this is text", - Severity: "NORMAL", - Anchor: BitBucketPendingCommentAnchor{ - Path: "foo.yaml", - Line: 5, - DiffType: "EFFECTIVE", - LineType: "CONTEXT", - FileType: "FROM", - }, - }, - changes: nil, - }, - { - description: "path not found in changes", - input: pendingComment{ - severity: "NORMAL", - text: "this is text", - path: "foo.yaml", - line: 5, - }, - output: BitBucketPendingComment{ - Text: "this is text", - Severity: "NORMAL", - Anchor: BitBucketPendingCommentAnchor{ - Path: "foo.yaml", - Line: 5, - DiffType: "EFFECTIVE", - LineType: "CONTEXT", - FileType: "FROM", - }, - }, - changes: &bitBucketPRChanges{ - pathModifiedLines: map[string][]int{"bar.yaml": {1, 2, 3}}, - pathLineMapping: map[string]map[int]int{"bar.yaml": {1: 1, 2: 5, 3: 3}}, - }, - }, - { - description: "path found in changes", - input: pendingComment{ - severity: "NORMAL", - text: "this is text", - path: "foo.yaml", - line: 5, - }, - output: BitBucketPendingComment{ - Text: "this is text", - Severity: "NORMAL", - Anchor: BitBucketPendingCommentAnchor{ - Path: "foo.yaml", - Line: 5, - DiffType: "EFFECTIVE", - LineType: "ADDED", - FileType: "TO", - }, - }, - changes: &bitBucketPRChanges{ - pathModifiedLines: map[string][]int{"foo.yaml": {1, 3, 5}}, - pathLineMapping: map[string]map[int]int{"foo.yaml": {1: 1, 3: 3, 5: 4}}, - }, - }, - { - description: "anchor before sets REMOVED lineType", - input: pendingComment{ - severity: "NORMAL", - text: "this is text", - path: "foo.yaml", - line: 5, - anchor: checks.AnchorBefore, - }, - output: BitBucketPendingComment{ - Text: "this is text", - Severity: "NORMAL", - Anchor: BitBucketPendingCommentAnchor{ - Path: "foo.yaml", - Line: 5, - DiffType: "EFFECTIVE", - LineType: "REMOVED", - FileType: "FROM", - }, - }, - changes: nil, - }, - { - description: "line not modified uses line mapping", - input: pendingComment{ - severity: "NORMAL", - text: "this is text", - path: "foo.yaml", - line: 5, - }, - output: BitBucketPendingComment{ - Text: "this is text", - Severity: "NORMAL", - Anchor: BitBucketPendingCommentAnchor{ - Path: "foo.yaml", - Line: 10, - DiffType: "EFFECTIVE", - LineType: "CONTEXT", - FileType: "FROM", - }, - }, - changes: &bitBucketPRChanges{ - pathModifiedLines: map[string][]int{"foo.yaml": {1, 3}}, - pathLineMapping: map[string]map[int]int{"foo.yaml": {5: 10}}, - }, - }, - { - description: "line not in mapping keeps original line", - input: pendingComment{ - severity: "NORMAL", - text: "this is text", - path: "foo.yaml", - line: 5, - }, - output: BitBucketPendingComment{ - Text: "this is text", - Severity: "NORMAL", - Anchor: BitBucketPendingCommentAnchor{ - Path: "foo.yaml", - Line: 5, - DiffType: "EFFECTIVE", - LineType: "CONTEXT", - FileType: "FROM", - }, - }, - changes: &bitBucketPRChanges{ - pathModifiedLines: map[string][]int{"foo.yaml": {1, 3}}, - pathLineMapping: map[string]map[int]int{"foo.yaml": {1: 1, 3: 3}}, - }, - }, - } - - for _, tc := range testCases { - t.Run(tc.description, func(t *testing.T) { - slog.SetDefault(slogt.New(t)) - out := tc.input.toBitBucketComment(tc.changes) - require.Equal(t, tc.output, out, "pendingComment.toBitBucketComment() returned wrong BitBucketPendingComment") - }) - } -} - -func TestReportToAnnotation(t *testing.T) { - type testCaseT struct { - description string - output BitBucketAnnotation - input Report - } - - testCases := []testCaseT{ - { - description: "fatal report on modified line", - input: Report{ - Path: discovery.Path{ - SymlinkTarget: "foo.yaml", - Name: "foo.yaml", - }, - Changes: &discovery.Changes{ - OldPath: "", - Lines: git.LineNumbers{ - {Before: 0, After: 4, Modified: true}, - {Before: 0, After: 5, Modified: true}, - {Before: 0, After: 6, Modified: true}, - }, - }, - Problem: checks.Problem{ - Lines: diags.LineRange{ - First: 5, - Last: 5, - }, - Reporter: "mock", - Summary: "report text", - Details: "mock details", - Severity: checks.Fatal, - }, - }, - output: BitBucketAnnotation{ - Path: "foo.yaml", - Line: 5, - Message: "mock: report text", - Severity: "HIGH", - Type: "BUG", - Link: "https://cloudflare.github.io/pint/checks/mock.html", - }, - }, - { - description: "bug report on modified line", - input: Report{ - Path: discovery.Path{ - SymlinkTarget: "foo.yaml", - Name: "foo.yaml", - }, - Changes: &discovery.Changes{ - OldPath: "", - Lines: git.LineNumbers{ - {Before: 0, After: 4, Modified: true}, - {Before: 0, After: 5, Modified: true}, - {Before: 0, After: 6, Modified: true}, - }, - }, - Problem: checks.Problem{ - Lines: diags.LineRange{ - First: 5, - Last: 5, - }, - Reporter: "mock", - Summary: "report text", - Severity: checks.Bug, - }, - }, - output: BitBucketAnnotation{ - Path: "foo.yaml", - Line: 5, - Message: "mock: report text", - Severity: "MEDIUM", - Type: "BUG", - Link: "https://cloudflare.github.io/pint/checks/mock.html", - }, - }, - { - description: "warning report on modified line", - input: Report{ - Path: discovery.Path{ - SymlinkTarget: "foo.yaml", - Name: "foo.yaml", - }, - Changes: &discovery.Changes{ - OldPath: "", - Lines: git.LineNumbers{ - {Before: 0, After: 4, Modified: true}, - {Before: 0, After: 5, Modified: true}, - {Before: 0, After: 6, Modified: true}, - }, - }, - Problem: checks.Problem{ - Lines: diags.LineRange{ - First: 5, - Last: 5, - }, - Reporter: "mock", - Summary: "report text", - Severity: checks.Warning, - }, - }, - output: BitBucketAnnotation{ - Path: "foo.yaml", - Line: 5, - Message: "mock: report text", - Severity: "LOW", - Type: "CODE_SMELL", - Link: "https://cloudflare.github.io/pint/checks/mock.html", - }, - }, - { - description: "information report on modified line", - input: Report{ - Path: discovery.Path{ - SymlinkTarget: "foo.yaml", - Name: "foo.yaml", - }, - Changes: &discovery.Changes{ - OldPath: "", - Lines: git.LineNumbers{ - {Before: 0, After: 4, Modified: true}, - {Before: 0, After: 5, Modified: true}, - {Before: 0, After: 6, Modified: true}, - }, - }, - Problem: checks.Problem{ - Lines: diags.LineRange{ - First: 5, - Last: 5, - }, - Reporter: "mock", - Summary: "report text", - Severity: checks.Information, - }, - }, - output: BitBucketAnnotation{ - Path: "foo.yaml", - Line: 5, - Message: "mock: report text", - Severity: "LOW", - Type: "CODE_SMELL", - Link: "https://cloudflare.github.io/pint/checks/mock.html", - }, - }, - { - description: "fatal report on symlinked file", - input: Report{ - Path: discovery.Path{ - SymlinkTarget: "foo.yaml", - Name: "bar.yaml", - }, - Changes: &discovery.Changes{ - OldPath: "", - Lines: git.LineNumbers{ - {Before: 0, After: 4, Modified: true}, - {Before: 0, After: 5, Modified: true}, - {Before: 0, After: 6, Modified: true}, - }, - }, - Problem: checks.Problem{ - Lines: diags.LineRange{ - First: 5, - Last: 5, - }, - Reporter: "mock", - Summary: "report text", - Severity: checks.Fatal, - }, - }, - output: BitBucketAnnotation{ - Path: "foo.yaml", - Line: 5, - Message: "Problem detected on symlinked file bar.yaml: mock: report text", - Severity: "HIGH", - Type: "BUG", - Link: "https://cloudflare.github.io/pint/checks/mock.html", - }, - }, - { - description: "fatal report on symlinked file on unmodified line", - input: Report{ - Path: discovery.Path{ - SymlinkTarget: "foo.yaml", - Name: "bar.yaml", - }, - Changes: &discovery.Changes{ - OldPath: "", - Lines: git.LineNumbers{ - {Before: 0, After: 4, Modified: true}, - {Before: 0, After: 5, Modified: true}, - {Before: 0, After: 6, Modified: true}, - }, - }, - Problem: checks.Problem{ - Lines: diags.LineRange{ - First: 7, - Last: 7, - }, - Reporter: "mock", - Summary: "report text", - Severity: checks.Fatal, - }, - }, - output: BitBucketAnnotation{ - Path: "foo.yaml", - Line: 6, - Message: "Problem detected on symlinked file bar.yaml. Problem reported on unmodified line 7, annotation moved here: mock: report text", - Severity: "HIGH", - Type: "BUG", - Link: "https://cloudflare.github.io/pint/checks/mock.html", - }, - }, - { - description: "information report on unmodified line", - input: Report{ - Path: discovery.Path{ - SymlinkTarget: "foo.yaml", - Name: "foo.yaml", - }, - Changes: &discovery.Changes{ - OldPath: "", - Lines: git.LineNumbers{ - {Before: 0, After: 4, Modified: true}, - {Before: 0, After: 5, Modified: true}, - {Before: 0, After: 6, Modified: true}, - }, - }, - Problem: checks.Problem{ - Lines: diags.LineRange{ - First: 1, - Last: 1, - }, - Reporter: "mock", - Summary: "report text", - Severity: checks.Information, - }, - }, - output: BitBucketAnnotation{ - Path: "foo.yaml", - Line: 4, - Message: "Problem reported on unmodified line 1, annotation moved here: mock: report text", - Severity: "LOW", - Type: "CODE_SMELL", - Link: "https://cloudflare.github.io/pint/checks/mock.html", - }, - }, - { - // Verify that nil Changes doesn't panic and returns - // the problem line as-is. - description: "nil changes", - input: Report{ - Path: discovery.Path{ - SymlinkTarget: "foo.yaml", - Name: "foo.yaml", - }, - Problem: checks.Problem{ - Lines: diags.LineRange{ - First: 5, - Last: 5, - }, - Reporter: "mock", - Summary: "report text", - Severity: checks.Warning, - }, - }, - output: BitBucketAnnotation{ - Path: "foo.yaml", - Line: 5, - Message: "mock: report text", - Severity: "LOW", - Type: "CODE_SMELL", - Link: "https://cloudflare.github.io/pint/checks/mock.html", - }, - }, - } - - for _, tc := range testCases { - t.Run(tc.description, func(t *testing.T) { - slog.SetDefault(slogt.New(t)) - out := reportToAnnotation(tc.input) - require.Equal(t, tc.output, out, "reportToAnnotation() returned wrong BitBucketAnnotation") - }) - } -} - -func TestBitBucketAPIRequest(t *testing.T) { - slog.SetDefault(slogt.New(t)) - - t.Run("successful request with body", func(t *testing.T) { - srv := httpmock.New(func(s *httpmock.Server) { - s.ExpectPost("/test"). - WithHeader("Content-Type", "application/json"). - WithHeader("Authorization", "Bearer test-token"). - Return(`{"result": "ok"}`). - Once() - })(t) - - bb := bitBucketAPI{ - uri: srv.URL(), - authToken: "test-token", - timeout: time.Second * 5, - } - - body := bytes.NewReader([]byte(`{"key": "value"}`)) - resp, err := bb.request(http.MethodPost, "/test", body) - require.NoError(t, err) - require.JSONEq(t, `{"result": "ok"}`, string(resp)) - }) - - t.Run("request with invalid URL", func(t *testing.T) { - bb := bitBucketAPI{ - uri: "://invalid-url", - authToken: "test-token", - timeout: time.Second, - } - - _, err := bb.request(http.MethodGet, "/test", nil) - require.Error(t, err) - }) - - t.Run("non-2xx response returns error", func(t *testing.T) { - srv := httpmock.New(func(s *httpmock.Server) { - s.ExpectGet("/test"). - ReturnCode(http.StatusBadRequest). - Return("Bad Request"). - Once() - })(t) - - bb := bitBucketAPI{ - uri: srv.URL(), - authToken: "test-token", - timeout: time.Second * 5, - } - - _, err := bb.request(http.MethodGet, "/test", nil) - require.Error(t, err) - require.Equal(t, "GET request failed", err.Error()) - }) -} - -func TestBitBucketAPIPruneComments(t *testing.T) { - slog.SetDefault(slogt.New(t)) - - t.Run("keeps matching comment", func(t *testing.T) { - // No requests should be made when comment matches. - srv := httpmock.New(func(_ *httpmock.Server) {})(t) - - bb := bitBucketAPI{ - uri: srv.URL(), - authToken: "test-token", - timeout: time.Second * 5, - project: "proj", - repo: "repo", - } - - pr := &bitBucketPR{ID: 1} - currentComments := []bitBucketComment{ - { - id: 1, - version: 1, - text: "test comment", - severity: "NORMAL", - anchor: BitBucketCommentAnchor{ - Path: "foo.yaml", - Line: 10, - LineType: "ADDED", - DiffType: "EFFECTIVE", - }, - }, - } - pendingComments := []BitBucketPendingComment{ - { - Text: "test comment", - Severity: "NORMAL", - Anchor: BitBucketPendingCommentAnchor{ - Path: "foo.yaml", - Line: 10, - LineType: "ADDED", - DiffType: "EFFECTIVE", - }, - }, - } - - bb.pruneComments(pr, currentComments, pendingComments) - require.Empty(t, srv.Requests, "no requests should be made when comment matches") - }) - - t.Run("deletes comment with no replies", func(t *testing.T) { - srv := httpmock.New(func(s *httpmock.Server) { - s.ExpectDelete("/rest/api/1.0/projects/proj/repos/repo/pull-requests/1/comments/1?version=1"). - Once() - })(t) - - bb := bitBucketAPI{ - uri: srv.URL(), - authToken: "test-token", - timeout: time.Second * 5, - project: "proj", - repo: "repo", - } - - pr := &bitBucketPR{ID: 1} - currentComments := []bitBucketComment{ - { - id: 1, - version: 1, - text: "old comment", - severity: "NORMAL", - replies: 0, - anchor: BitBucketCommentAnchor{ - Path: "foo.yaml", - Line: 10, - LineType: "ADDED", - DiffType: "EFFECTIVE", - }, - }, - } - pendingComments := []BitBucketPendingComment{} - - bb.pruneComments(pr, currentComments, pendingComments) - require.Len(t, srv.Requests, 1, "expected delete to be called") - require.Equal(t, http.MethodDelete, srv.Requests[0].Method()) - }) - - t.Run("resolves blocker comment with replies", func(t *testing.T) { - srv := httpmock.New(func(s *httpmock.Server) { - s.ExpectPut("/rest/api/1.0/projects/proj/repos/repo/pull-requests/1/comments/1"). - Once() - })(t) - - bb := bitBucketAPI{ - uri: srv.URL(), - authToken: "test-token", - timeout: time.Second * 5, - project: "proj", - repo: "repo", - } - - pr := &bitBucketPR{ID: 1} - currentComments := []bitBucketComment{ - { - id: 1, - version: 1, - text: "old comment", - severity: "BLOCKER", - replies: 1, - anchor: BitBucketCommentAnchor{ - Path: "foo.yaml", - Line: 10, - LineType: "ADDED", - DiffType: "EFFECTIVE", - }, - }, - } - pendingComments := []BitBucketPendingComment{} - - bb.pruneComments(pr, currentComments, pendingComments) - require.Len(t, srv.Requests, 1, "expected resolve to be called") - require.Equal(t, http.MethodPut, srv.Requests[0].Method()) - }) - - t.Run("updates severity and resolves normal comment with replies", func(t *testing.T) { - srv := httpmock.New(func(s *httpmock.Server) { - // First PUT: severity update, second PUT: resolve. - s.ExpectPut("/rest/api/1.0/projects/proj/repos/repo/pull-requests/1/comments/1"). - Once() - s.ExpectPut("/rest/api/1.0/projects/proj/repos/repo/pull-requests/1/comments/1"). - Once() - })(t) - - bb := bitBucketAPI{ - uri: srv.URL(), - authToken: "test-token", - timeout: time.Second * 5, - project: "proj", - repo: "repo", - } - - pr := &bitBucketPR{ID: 1} - currentComments := []bitBucketComment{ - { - id: 1, - version: 1, - text: "old comment", - severity: "NORMAL", - replies: 1, - anchor: BitBucketCommentAnchor{ - Path: "foo.yaml", - Line: 10, - LineType: "ADDED", - DiffType: "EFFECTIVE", - }, - }, - } - pendingComments := []BitBucketPendingComment{} - - bb.pruneComments(pr, currentComments, pendingComments) - require.Len(t, srv.Requests, 2, "expected severity update and resolve to be called") - require.Equal(t, http.MethodPut, srv.Requests[0].Method()) - require.Equal(t, http.MethodPut, srv.Requests[1].Method()) - }) - - t.Run("handles COMMIT diffType", func(t *testing.T) { - srv := httpmock.New(func(s *httpmock.Server) { - s.ExpectDelete("/rest/api/1.0/projects/proj/repos/repo/pull-requests/1/comments/1?version=1"). - Once() - })(t) - - bb := bitBucketAPI{ - uri: srv.URL(), - authToken: "test-token", - timeout: time.Second * 5, - project: "proj", - repo: "repo", - } - - pr := &bitBucketPR{ID: 1} - currentComments := []bitBucketComment{ - { - id: 1, - version: 1, - text: "commit comment", - severity: "NORMAL", - replies: 0, - anchor: BitBucketCommentAnchor{ - Path: "foo.yaml", - Line: 10, - LineType: "ADDED", - DiffType: "COMMIT", - }, - }, - } - pendingComments := []BitBucketPendingComment{ - { - Text: "different comment", - Anchor: BitBucketPendingCommentAnchor{ - Path: "foo.yaml", - Line: 10, - LineType: "ADDED", - DiffType: "EFFECTIVE", - }, - }, - } - - bb.pruneComments(pr, currentComments, pendingComments) - require.Len(t, srv.Requests, 1, "expected delete to be called for COMMIT diffType") - require.Equal(t, http.MethodDelete, srv.Requests[0].Method()) - }) -} - -func TestBitBucketAPIGetPullRequestComments(t *testing.T) { - slog.SetDefault(slogt.New(t)) - - t.Run("filters comments correctly", func(t *testing.T) { - activities := BitBucketPullRequestActivities{ - IsLastPage: true, - Values: []BitBucketPullRequestActivity{ - { - Action: "COMMENTED", - CommentAction: "ADDED", - Comment: BitBucketPullRequestComment{ - ID: 1, - Version: 1, - State: "OPEN", - Author: BitBucketCommentAuthor{Name: "testuser"}, - Text: "valid comment", - }, - CommentAnchor: BitBucketCommentAnchor{Path: "foo.yaml", Line: 10}, - }, - { - Action: "APPROVED", - CommentAction: "ADDED", - Comment: BitBucketPullRequestComment{ - ID: 2, - State: "OPEN", - Author: BitBucketCommentAuthor{Name: "testuser"}, - }, - }, - { - Action: "COMMENTED", - CommentAction: "EDITED", - Comment: BitBucketPullRequestComment{ - ID: 3, - State: "OPEN", - Author: BitBucketCommentAuthor{Name: "testuser"}, - }, - }, - { - Action: "COMMENTED", - CommentAction: "ADDED", - Comment: BitBucketPullRequestComment{ - ID: 4, - State: "RESOLVED", - Author: BitBucketCommentAuthor{Name: "testuser"}, - }, - }, - { - Action: "COMMENTED", - CommentAction: "ADDED", - Comment: BitBucketPullRequestComment{ - ID: 5, - State: "OPEN", - Author: BitBucketCommentAuthor{Name: "otheruser"}, - }, - }, - { - Action: "COMMENTED", - CommentAction: "ADDED", - Comment: BitBucketPullRequestComment{ - ID: 6, - State: "OPEN", - Author: BitBucketCommentAuthor{Name: "testuser"}, - Severity: "BLOCKER", - Resolved: true, - }, - }, - { - Action: "COMMENTED", - CommentAction: "ADDED", - Comment: BitBucketPullRequestComment{ - ID: 7, - State: "OPEN", - Author: BitBucketCommentAuthor{Name: "testuser"}, - Severity: "NORMAL", - }, - CommentAnchor: BitBucketCommentAnchor{Orphaned: true}, - }, - }, - } - activitiesJSON, _ := json.Marshal(activities) - - srv := httpmock.New(func(s *httpmock.Server) { - s.ExpectGet("/plugins/servlet/applinks/whoami"). - Return("testuser"). - Once() - s.ExpectGet("/rest/api/latest/projects/proj/repos/repo/pull-requests/1/activities?start=0"). - ReturnHeader("Content-Type", "application/json"). - Return(string(activitiesJSON)). - Once() - })(t) - - bb := bitBucketAPI{ - uri: srv.URL(), - authToken: "test-token", - timeout: time.Second * 5, - project: "proj", - repo: "repo", - } - - pr := &bitBucketPR{ID: 1} - comments, err := bb.getPullRequestComments(pr) - require.NoError(t, err) - require.Len(t, comments, 1) - require.Equal(t, 1, comments[0].id) - require.Equal(t, "valid comment", comments[0].text) - }) - - t.Run("handles pagination", func(t *testing.T) { - page1, _ := json.Marshal(BitBucketPullRequestActivities{ - IsLastPage: false, - NextPageStart: 1, - Values: []BitBucketPullRequestActivity{ - { - Action: "COMMENTED", - CommentAction: "ADDED", - Comment: BitBucketPullRequestComment{ - ID: 1, - State: "OPEN", - Author: BitBucketCommentAuthor{Name: "testuser"}, - Text: "comment 1", - }, - CommentAnchor: BitBucketCommentAnchor{Path: "foo.yaml"}, - }, - }, - }) - page2, _ := json.Marshal(BitBucketPullRequestActivities{ - IsLastPage: true, - Values: []BitBucketPullRequestActivity{ - { - Action: "COMMENTED", - CommentAction: "ADDED", - Comment: BitBucketPullRequestComment{ - ID: 2, - State: "OPEN", - Author: BitBucketCommentAuthor{Name: "testuser"}, - Text: "comment 2", - }, - CommentAnchor: BitBucketCommentAnchor{Path: "bar.yaml"}, - }, - }, - }) - - srv := httpmock.New(func(s *httpmock.Server) { - s.ExpectGet("/plugins/servlet/applinks/whoami"). - Return("testuser"). - Once() - s.ExpectGet("/rest/api/latest/projects/proj/repos/repo/pull-requests/1/activities?start=0"). - ReturnHeader("Content-Type", "application/json"). - Return(string(page1)). - Once() - s.ExpectGet("/rest/api/latest/projects/proj/repos/repo/pull-requests/1/activities?start=1"). - ReturnHeader("Content-Type", "application/json"). - Return(string(page2)). - Once() - })(t) - - bb := bitBucketAPI{ - uri: srv.URL(), - authToken: "test-token", - timeout: time.Second * 5, - project: "proj", - repo: "repo", - } - - pr := &bitBucketPR{ID: 1} - comments, err := bb.getPullRequestComments(pr) - require.NoError(t, err) - require.Len(t, comments, 2) - }) - - t.Run("returns error on invalid JSON", func(t *testing.T) { - srv := httpmock.New(func(s *httpmock.Server) { - s.ExpectGet("/plugins/servlet/applinks/whoami"). - Return("testuser"). - Once() - s.ExpectGet("/rest/api/latest/projects/proj/repos/repo/pull-requests/1/activities?start=0"). - Return("invalid json"). - Once() - })(t) - - bb := bitBucketAPI{ - uri: srv.URL(), - authToken: "test-token", - timeout: time.Second * 5, - project: "proj", - repo: "repo", - } - - pr := &bitBucketPR{ID: 1} - _, err := bb.getPullRequestComments(pr) - require.Error(t, err) - }) -} - -func TestFindPullRequestForBranchErrors(t *testing.T) { - slog.SetDefault(slogt.New(t)) - - t.Run("returns error on invalid JSON", func(t *testing.T) { - srv := httpmock.New(func(s *httpmock.Server) { - s.ExpectGet("/rest/api/1.0/projects/proj/repos/repo/commits/commit123/pull-requests?start=0"). - Return("invalid json"). - Once() - })(t) - - bb := bitBucketAPI{ - uri: srv.URL(), - authToken: "test-token", - timeout: time.Second * 5, - project: "proj", - repo: "repo", - } - - _, err := bb.findPullRequestForBranch("feature", "commit123") - require.Error(t, err) - }) - - t.Run("paginates through results", func(t *testing.T) { - page1, _ := json.Marshal(BitBucketPullRequests{ - IsLastPage: false, - NextPageStart: 1, - Values: []BitBucketPullRequest{ - {ID: 1, Open: true, FromRef: BitBucketRef{ID: "refs/heads/other"}, ToRef: BitBucketRef{ID: "refs/heads/main"}}, - }, - }) - page2, _ := json.Marshal(BitBucketPullRequests{ - IsLastPage: true, - Values: []BitBucketPullRequest{ - {ID: 2, Open: true, FromRef: BitBucketRef{ID: "refs/heads/feature", Commit: "abc123"}, ToRef: BitBucketRef{ID: "refs/heads/main", Commit: "def456"}}, - }, - }) - - srv := httpmock.New(func(s *httpmock.Server) { - s.ExpectGet("/rest/api/1.0/projects/proj/repos/repo/commits/commit123/pull-requests?start=0"). - ReturnHeader("Content-Type", "application/json"). - Return(string(page1)). - Once() - s.ExpectGet("/rest/api/1.0/projects/proj/repos/repo/commits/commit123/pull-requests?start=1"). - ReturnHeader("Content-Type", "application/json"). - Return(string(page2)). - Once() - })(t) - - bb := bitBucketAPI{ - uri: srv.URL(), - authToken: "test-token", - timeout: time.Second * 5, - project: "proj", - repo: "repo", - } - - pr, err := bb.findPullRequestForBranch("feature", "commit123") - require.NoError(t, err) - require.NotNil(t, pr) - require.Equal(t, 2, pr.ID) - }) -} - -func TestGetPullRequestChangesErrors(t *testing.T) { - slog.SetDefault(slogt.New(t)) - - t.Run("returns error on invalid JSON", func(t *testing.T) { - srv := httpmock.New(func(s *httpmock.Server) { - s.ExpectGet("/rest/api/1.0/projects/proj/repos/repo/pull-requests/1/changes?start=0"). - Return("invalid json"). - Once() - })(t) - - bb := bitBucketAPI{ - uri: srv.URL(), - authToken: "test-token", - timeout: time.Second * 5, - project: "proj", - repo: "repo", - } - - pr := &bitBucketPR{ID: 1} - _, err := bb.getPullRequestChanges(pr) - require.Error(t, err) - }) - - t.Run("returns error on getFileDiff failure", func(t *testing.T) { - changesJSON, _ := json.Marshal(BitBucketPullRequestChanges{ - IsLastPage: true, - Values: []BitBucketPullRequestChange{{Path: BitBucketPath{ToString: "file.yaml"}}}, - }) - - srv := httpmock.New(func(s *httpmock.Server) { - s.ExpectGet("/rest/api/1.0/projects/proj/repos/repo/pull-requests/1/changes?start=0"). - ReturnHeader("Content-Type", "application/json"). - Return(string(changesJSON)). - Once() - s.ExpectGet("/rest/api/latest/projects/proj/repos/repo/commits/abc/diff/file.yaml?contextLines=10000&since=def&whitespace=show&withComments=false"). - ReturnCode(http.StatusInternalServerError). - Once() - })(t) - - bb := bitBucketAPI{ - uri: srv.URL(), - authToken: "test-token", - timeout: time.Second * 5, - project: "proj", - repo: "repo", - } - - pr := &bitBucketPR{ID: 1, srcHead: "abc", dstHead: "def"} - _, err := bb.getPullRequestChanges(pr) - require.Error(t, err) - }) - - t.Run("paginates through results", func(t *testing.T) { - page1, _ := json.Marshal(BitBucketPullRequestChanges{ - IsLastPage: false, - NextPageStart: 1, - Values: []BitBucketPullRequestChange{}, - }) - page2, _ := json.Marshal(BitBucketPullRequestChanges{ - IsLastPage: true, - Values: []BitBucketPullRequestChange{}, - }) - - srv := httpmock.New(func(s *httpmock.Server) { - s.ExpectGet("/rest/api/1.0/projects/proj/repos/repo/pull-requests/1/changes?start=0"). - ReturnHeader("Content-Type", "application/json"). - Return(string(page1)). - Once() - s.ExpectGet("/rest/api/1.0/projects/proj/repos/repo/pull-requests/1/changes?start=1"). - ReturnHeader("Content-Type", "application/json"). - Return(string(page2)). - Once() - })(t) - - bb := bitBucketAPI{ - uri: srv.URL(), - authToken: "test-token", - timeout: time.Second * 5, - project: "proj", - repo: "repo", - } - - pr := &bitBucketPR{ID: 1, srcHead: "abc", dstHead: "def"} - _, err := bb.getPullRequestChanges(pr) - require.NoError(t, err) - }) -} - -func TestGetFileDiffErrors(t *testing.T) { - slog.SetDefault(slogt.New(t)) - - srv := httpmock.New(func(s *httpmock.Server) { - s.ExpectGet("/rest/api/latest/projects/proj/repos/repo/commits/abc/diff/file.yaml?contextLines=10000&since=def&whitespace=show&withComments=false"). - Return("invalid json"). - Once() - })(t) - - bb := bitBucketAPI{ - uri: srv.URL(), - authToken: "test-token", - timeout: time.Second * 5, - project: "proj", - repo: "repo", - } - - pr := &bitBucketPR{ID: 1, srcHead: "abc", dstHead: "def"} - _, _, err := bb.getFileDiff(pr, "file.yaml") - require.Error(t, err) -} - -func TestBitBucketAPIErrorHandling(t *testing.T) { - slog.SetDefault(slogt.New(t)) - - type testCaseT struct { - run func(bb bitBucketAPI, pr *bitBucketPR) - name string - } - - testCases := []testCaseT{ - { - name: "updateSeverity logs error on failure", - run: func(bb bitBucketAPI, pr *bitBucketPR) { - cur := bitBucketComment{id: 1, version: 1, anchor: BitBucketCommentAnchor{Path: "file.yaml", Line: 10}} - bb.updateSeverity(pr, cur, "BLOCKER") - }, - }, - { - name: "resolveTask logs error on failure", - run: func(bb bitBucketAPI, pr *bitBucketPR) { - cur := bitBucketComment{id: 1, version: 1} - bb.resolveTask(pr, cur) - }, - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(_ *testing.T) { - srv := httpmock.New(func(s *httpmock.Server) { - s.ExpectPut("/rest/api/1.0/projects/proj/repos/repo/pull-requests/1/comments/1"). - ReturnCode(http.StatusInternalServerError). - Once() - })(t) - - bb := bitBucketAPI{ - uri: srv.URL(), - authToken: "test-token", - timeout: time.Second * 5, - project: "proj", - repo: "repo", - } - - pr := &bitBucketPR{ID: 1} - tc.run(bb, pr) - }) - } -} - -func TestAddCommentsSkipsDuplicates(t *testing.T) { - slog.SetDefault(slogt.New(t)) - - srv := httpmock.New(func(s *httpmock.Server) { - // Only the new comment should be posted, duplicate skipped. - s.ExpectPost("/rest/api/1.0/projects/proj/repos/repo/pull-requests/1/comments"). - Return("{}"). - Once() - })(t) - - bb := bitBucketAPI{ - uri: srv.URL(), - authToken: "test-token", - timeout: time.Second * 5, - project: "proj", - repo: "repo", - } - - pr := &bitBucketPR{ID: 1} - currentComments := []bitBucketComment{ - { - id: 1, - text: "existing comment", - anchor: BitBucketCommentAnchor{ - Path: "file.yaml", - Line: 10, - LineType: "ADDED", - DiffType: "EFFECTIVE", - }, - }, - } - pendingComments := []BitBucketPendingComment{ - { - Text: "existing comment", - Anchor: BitBucketPendingCommentAnchor{ - Path: "file.yaml", - Line: 10, - LineType: "ADDED", - DiffType: "EFFECTIVE", - }, - }, - { - Text: "new comment", - Anchor: BitBucketPendingCommentAnchor{ - Path: "file.yaml", - Line: 20, - LineType: "ADDED", - DiffType: "EFFECTIVE", - }, - }, - } - - err := bb.addComments(pr, currentComments, pendingComments) - require.NoError(t, err) - require.Len(t, srv.Requests, 1, "only the new comment should be added, duplicate skipped") - require.Equal(t, http.MethodPost, srv.Requests[0].Method()) -} diff --git a/internal/reporter/bitbucket_comments_test.go b/internal/reporter/bitbucket_comments_test.go deleted file mode 100644 index 05d49bf6..00000000 --- a/internal/reporter/bitbucket_comments_test.go +++ /dev/null @@ -1,759 +0,0 @@ -package reporter - -import ( - "fmt" - "log/slog" - "strings" - "testing" - "time" - - "github.com/google/go-cmp/cmp" - "github.com/neilotoole/slogt" - - "github.com/cloudflare/pint/internal/checks" - "github.com/cloudflare/pint/internal/diags" - "github.com/cloudflare/pint/internal/discovery" - "github.com/cloudflare/pint/internal/git" -) - -func TestBitBucketMakeComments(t *testing.T) { - type testCaseT struct { - changes *bitBucketPRChanges - description string - comments []BitBucketPendingComment - summary Summary - maxComments int - showDuplicates bool - } - - commentBody := func(icon, severity, reporter, text string) string { - return fmt.Sprintf( - ":%s: **%s** reported by [pint](https://cloudflare.github.io/pint/) **%s** check.\n\n------\n\n%s\n\n------\n\n:information_source: To see documentation covering this check and instructions on how to resolve it [click here](https://cloudflare.github.io/pint/checks/%s.html).\n", - icon, severity, reporter, text, reporter, - ) - } - - testCases := []testCaseT{ - { - description: "empty summary", - maxComments: 50, - comments: []BitBucketPendingComment{}, - }, - { - description: "report not included in changes", - maxComments: 50, - summary: Summary{reports: []Report{ - { - Path: discovery.Path{ - SymlinkTarget: "rule.yaml", - Name: "rule.yaml", - }, - Changes: &discovery.Changes{ - OldPath: "", - Lines: git.LineNumbers{ - {Before: 0, After: 2, Modified: true}, - {Before: 0, After: 3, Modified: true}, - }, - }, - Problem: checks.Problem{ - Severity: checks.Bug, - }, - }, - }}, - changes: &bitBucketPRChanges{}, - comments: []BitBucketPendingComment{}, - }, - { - description: "reports included in changes", - maxComments: 50, - summary: Summary{reports: []Report{ - { - Path: discovery.Path{ - SymlinkTarget: "rule.yaml", - Name: "rule.yaml", - }, - Changes: &discovery.Changes{ - OldPath: "", - Lines: git.LineNumbers{ - {Before: 0, After: 2, Modified: true}, - {Before: 0, After: 3, Modified: true}, - }, - }, - Problem: checks.Problem{ - Severity: checks.Bug, - Lines: diags.LineRange{ - First: 2, - Last: 2, - }, - Summary: "first error", - Details: "first details", - Reporter: "r1", - }, - }, - { - Path: discovery.Path{ - SymlinkTarget: "rule.yaml", - Name: "rule.yaml", - }, - Changes: &discovery.Changes{ - OldPath: "", - Lines: git.LineNumbers{ - {Before: 0, After: 2, Modified: true}, - {Before: 0, After: 3, Modified: true}, - }, - }, - Problem: checks.Problem{ - Severity: checks.Warning, - Lines: diags.LineRange{ - First: 3, - Last: 3, - }, - Summary: "second error", - Details: "second details", - Reporter: "r1", - }, - }, - { - Path: discovery.Path{ - SymlinkTarget: "rule.yaml", - Name: "rule.yaml", - }, - Changes: &discovery.Changes{ - OldPath: "", - Lines: git.LineNumbers{ - {Before: 0, After: 2, Modified: true}, - {Before: 0, After: 3, Modified: true}, - }, - }, - Problem: checks.Problem{ - Severity: checks.Bug, - Lines: diags.LineRange{ - First: 2, - Last: 2, - }, - Summary: "third error", - Details: "third details", - Reporter: "r2", - }, - }, - { - Path: discovery.Path{ - SymlinkTarget: "rule.yaml", - Name: "symlink.yaml", - }, - Changes: &discovery.Changes{ - OldPath: "", - Lines: git.LineNumbers{ - {Before: 0, After: 2, Modified: true}, - {Before: 0, After: 3, Modified: true}, - }, - }, - Problem: checks.Problem{ - Severity: checks.Bug, - Lines: diags.LineRange{ - First: 2, - Last: 2, - }, - Summary: "fourth error", - Details: "fourth details", - Reporter: "r2", - }, - }, - { - Path: discovery.Path{ - SymlinkTarget: "second.yaml", - Name: "second.yaml", - }, - Changes: &discovery.Changes{ - OldPath: "", - Lines: git.LineNumbers{ - {Before: 0, After: 1, Modified: true}, - {Before: 0, After: 2, Modified: true}, - {Before: 0, After: 3, Modified: true}, - }, - }, - Problem: checks.Problem{ - Anchor: checks.AnchorBefore, - Severity: checks.Bug, - Lines: diags.LineRange{ - First: 2, - Last: 2, - }, - Summary: "fifth error", - Details: "fifth details", - Reporter: "r2", - }, - }, - { - Path: discovery.Path{ - SymlinkTarget: "second.yaml", - Name: "second.yaml", - }, - Changes: &discovery.Changes{ - OldPath: "", - Lines: git.LineNumbers{ - {Before: 0, After: 1, Modified: true}, - {Before: 0, After: 2, Modified: true}, - {Before: 0, After: 3, Modified: true}, - }, - }, - Problem: checks.Problem{ - Severity: checks.Bug, - Lines: diags.LineRange{ - First: 2, - Last: 2, - }, - Summary: "sixth error", - Details: "sixth details", - Reporter: "r2", - }, - }, - }}, - changes: &bitBucketPRChanges{ - pathModifiedLines: map[string][]int{ - "rule.yaml": {2, 3}, - "second.yaml": {1, 2, 3}, - }, - pathLineMapping: map[string]map[int]int{ - "rule.yaml": {2: 2, 3: 3}, - "second.yaml": {1: 5, 2: 6, 3: 7}, - }, - }, - comments: []BitBucketPendingComment{ - { - Text: commentBody("stop_sign", "Bug", "r1", "first error\n\nfirst details"), - Severity: "BLOCKER", - Anchor: BitBucketPendingCommentAnchor{ - Path: "rule.yaml", - Line: 2, - LineType: "ADDED", - FileType: "TO", - DiffType: "EFFECTIVE", - }, - }, - { - Text: commentBody("warning", "Warning", "r1", "second error\n\nsecond details"), - Severity: "NORMAL", - Anchor: BitBucketPendingCommentAnchor{ - Path: "rule.yaml", - Line: 3, - LineType: "ADDED", - FileType: "TO", - DiffType: "EFFECTIVE", - }, - }, - { - Text: commentBody("stop_sign", "Bug", "r2", "third error\n\nthird details\n\n------\n\nfourth error\n\nfourth details\n\n:leftwards_arrow_with_hook: This problem was detected on a symlinked file `symlink.yaml`."), - Severity: "BLOCKER", - Anchor: BitBucketPendingCommentAnchor{ - Path: "rule.yaml", - Line: 2, - LineType: "ADDED", - FileType: "TO", - DiffType: "EFFECTIVE", - }, - }, - { - Text: commentBody("stop_sign", "Bug", "r2", "fifth error\n\nfifth details"), - Severity: "BLOCKER", - Anchor: BitBucketPendingCommentAnchor{ - Path: "second.yaml", - Line: 2, - LineType: "REMOVED", - FileType: "FROM", - DiffType: "EFFECTIVE", - }, - }, - { - Text: commentBody("stop_sign", "Bug", "r2", "sixth error\n\nsixth details"), - Severity: "BLOCKER", - Anchor: BitBucketPendingCommentAnchor{ - Path: "second.yaml", - Line: 2, - LineType: "ADDED", - FileType: "TO", - DiffType: "EFFECTIVE", - }, - }, - }, - }, - { - description: "dedup reporter", - maxComments: 50, - summary: Summary{reports: []Report{ - { - Path: discovery.Path{ - SymlinkTarget: "rule.yaml", - Name: "rule.yaml", - }, - Changes: &discovery.Changes{ - OldPath: "", - Lines: git.LineNumbers{ - {Before: 0, After: 2, Modified: true}, - {Before: 0, After: 3, Modified: true}, - }, - }, - Problem: checks.Problem{ - Severity: checks.Bug, - Lines: diags.LineRange{ - First: 2, - Last: 2, - }, - Summary: "first error", - Details: "first details", - Reporter: "r1", - }, - }, - { - Path: discovery.Path{ - SymlinkTarget: "rule.yaml", - Name: "rule.yaml", - }, - Changes: &discovery.Changes{ - OldPath: "", - Lines: git.LineNumbers{ - {Before: 0, After: 2, Modified: true}, - {Before: 0, After: 3, Modified: true}, - }, - }, - Problem: checks.Problem{ - Severity: checks.Bug, - Lines: diags.LineRange{ - First: 2, - Last: 2, - }, - Summary: "second error", - Details: "second details", - Reporter: "r1", - }, - }, - }}, - changes: &bitBucketPRChanges{ - pathModifiedLines: map[string][]int{ - "rule.yaml": {2, 3}, - }, - pathLineMapping: map[string]map[int]int{ - "rule.yaml": {2: 2, 3: 3}, - }, - }, - comments: []BitBucketPendingComment{ - { - Text: commentBody("stop_sign", "Bug", "r1", "first error\n\nfirst details\n\n------\n\nsecond error\n\nsecond details"), - Severity: "BLOCKER", - Anchor: BitBucketPendingCommentAnchor{ - Path: "rule.yaml", - Line: 2, - LineType: "ADDED", - FileType: "TO", - DiffType: "EFFECTIVE", - }, - }, - }, - }, - { - description: "dedup identical reports", - maxComments: 50, - summary: Summary{reports: []Report{ - { - Path: discovery.Path{ - SymlinkTarget: "rule.yaml", - Name: "rule.yaml", - }, - Changes: &discovery.Changes{ - OldPath: "", - Lines: git.LineNumbers{ - {Before: 0, After: 2, Modified: true}, - {Before: 0, After: 3, Modified: true}, - }, - }, - Problem: checks.Problem{ - Severity: checks.Bug, - Lines: diags.LineRange{ - First: 2, - Last: 2, - }, - Summary: "my error", - Details: "my details", - Reporter: "r1", - }, - }, - { - Path: discovery.Path{ - SymlinkTarget: "rule.yaml", - Name: "rule.yaml", - }, - Changes: &discovery.Changes{ - OldPath: "", - Lines: git.LineNumbers{ - {Before: 0, After: 2, Modified: true}, - {Before: 0, After: 3, Modified: true}, - }, - }, - Problem: checks.Problem{ - Severity: checks.Bug, - Lines: diags.LineRange{ - First: 2, - Last: 2, - }, - Summary: "my error", - Details: "my details", - Reporter: "r1", - }, - }, - }}, - changes: &bitBucketPRChanges{ - pathModifiedLines: map[string][]int{ - "rule.yaml": {2, 3}, - }, - pathLineMapping: map[string]map[int]int{ - "rule.yaml": {2: 2, 3: 3}, - }, - }, - comments: []BitBucketPendingComment{ - { - Text: commentBody("stop_sign", "Bug", "r1", "my error\n\nmy details"), - Severity: "BLOCKER", - Anchor: BitBucketPendingCommentAnchor{ - Path: "rule.yaml", - Line: 2, - LineType: "ADDED", - FileType: "TO", - DiffType: "EFFECTIVE", - }, - }, - }, - }, - { - description: "dedup details", - maxComments: 50, - summary: Summary{reports: []Report{ - { - Path: discovery.Path{ - SymlinkTarget: "rule.yaml", - Name: "rule.yaml", - }, - Changes: &discovery.Changes{ - OldPath: "", - Lines: git.LineNumbers{ - {Before: 0, After: 2, Modified: true}, - {Before: 0, After: 3, Modified: true}, - }, - }, - Problem: checks.Problem{ - Severity: checks.Bug, - Lines: diags.LineRange{ - First: 2, - Last: 2, - }, - Summary: "first error", - Details: "shared details", - Reporter: "r1", - }, - }, - { - Path: discovery.Path{ - SymlinkTarget: "rule.yaml", - Name: "rule.yaml", - }, - Changes: &discovery.Changes{ - OldPath: "", - Lines: git.LineNumbers{ - {Before: 0, After: 2, Modified: true}, - {Before: 0, After: 3, Modified: true}, - }, - }, - Problem: checks.Problem{ - Severity: checks.Bug, - Lines: diags.LineRange{ - First: 2, - Last: 2, - }, - Summary: "second error", - Details: "shared details", - Reporter: "r1", - }, - }, - }}, - changes: &bitBucketPRChanges{ - pathModifiedLines: map[string][]int{ - "rule.yaml": {2, 3}, - }, - pathLineMapping: map[string]map[int]int{ - "rule.yaml": {2: 2, 3: 3}, - }, - }, - comments: []BitBucketPendingComment{ - { - Text: commentBody("stop_sign", "Bug", "r1", "first error\n\n------\n\nsecond error\n\n------\n\nshared details"), - Severity: "BLOCKER", - Anchor: BitBucketPendingCommentAnchor{ - Path: "rule.yaml", - Line: 2, - LineType: "ADDED", - FileType: "TO", - DiffType: "EFFECTIVE", - }, - }, - }, - }, - { - description: "maxComments 2", - maxComments: 2, - summary: Summary{reports: []Report{ - { - Path: discovery.Path{ - SymlinkTarget: "rule.yaml", - Name: "rule.yaml", - }, - Changes: &discovery.Changes{ - OldPath: "", - Lines: git.LineNumbers{ - {Before: 0, After: 2, Modified: true}, - {Before: 0, After: 3, Modified: true}, - }, - }, - Problem: checks.Problem{ - Severity: checks.Bug, - Lines: diags.LineRange{ - First: 2, - Last: 2, - }, - Summary: "first error", - Details: "first details", - Reporter: "r1", - }, - }, - { - Path: discovery.Path{ - SymlinkTarget: "rule.yaml", - Name: "rule.yaml", - }, - Changes: &discovery.Changes{ - OldPath: "", - Lines: git.LineNumbers{ - {Before: 0, After: 2, Modified: true}, - {Before: 0, After: 3, Modified: true}, - }, - }, - Problem: checks.Problem{ - Severity: checks.Warning, - Lines: diags.LineRange{ - First: 3, - Last: 3, - }, - Summary: "second error", - Details: "second details", - Reporter: "r1", - }, - }, - { - Path: discovery.Path{ - SymlinkTarget: "rule.yaml", - Name: "rule.yaml", - }, - Changes: &discovery.Changes{ - OldPath: "", - Lines: git.LineNumbers{ - {Before: 0, After: 2, Modified: true}, - {Before: 0, After: 3, Modified: true}, - }, - }, - Problem: checks.Problem{ - Severity: checks.Bug, - Lines: diags.LineRange{ - First: 2, - Last: 2, - }, - Summary: "third error", - Details: "third details", - Reporter: "r2", - }, - }, - { - Path: discovery.Path{ - SymlinkTarget: "rule.yaml", - Name: "symlink.yaml", - }, - Changes: &discovery.Changes{ - OldPath: "", - Lines: git.LineNumbers{ - {Before: 0, After: 2, Modified: true}, - {Before: 0, After: 3, Modified: true}, - }, - }, - Problem: checks.Problem{ - Severity: checks.Bug, - Lines: diags.LineRange{ - First: 2, - Last: 2, - }, - Summary: "fourth error", - Details: "fourth details", - Reporter: "r2", - }, - }, - { - Path: discovery.Path{ - SymlinkTarget: "second.yaml", - Name: "second.yaml", - }, - Changes: &discovery.Changes{ - OldPath: "", - Lines: git.LineNumbers{ - {Before: 0, After: 1, Modified: true}, - {Before: 0, After: 2, Modified: true}, - {Before: 0, After: 3, Modified: true}, - }, - }, - Problem: checks.Problem{ - Anchor: checks.AnchorBefore, - Severity: checks.Bug, - Lines: diags.LineRange{ - First: 2, - Last: 2, - }, - Summary: "fifth error", - Details: "fifth details", - Reporter: "r2", - }, - }, - { - Path: discovery.Path{ - SymlinkTarget: "second.yaml", - Name: "second.yaml", - }, - Changes: &discovery.Changes{ - OldPath: "", - Lines: git.LineNumbers{ - {Before: 0, After: 1, Modified: true}, - {Before: 0, After: 2, Modified: true}, - {Before: 0, After: 3, Modified: true}, - }, - }, - Problem: checks.Problem{ - Severity: checks.Bug, - Lines: diags.LineRange{ - First: 2, - Last: 2, - }, - Summary: "sixth error", - Details: "sixth details", - Reporter: "r2", - }, - }, - }}, - changes: &bitBucketPRChanges{ - pathModifiedLines: map[string][]int{ - "rule.yaml": {2, 3}, - "second.yaml": {1, 2, 3}, - }, - pathLineMapping: map[string]map[int]int{ - "rule.yaml": {2: 2, 3: 3}, - "second.yaml": {1: 5, 2: 6, 3: 7}, - }, - }, - comments: []BitBucketPendingComment{ - { - Text: commentBody("stop_sign", "Bug", "r1", "first error\n\nfirst details"), - Severity: "BLOCKER", - Anchor: BitBucketPendingCommentAnchor{ - Path: "rule.yaml", - Line: 2, - LineType: "ADDED", - FileType: "TO", - DiffType: "EFFECTIVE", - }, - }, - { - Text: commentBody("warning", "Warning", "r1", "second error\n\nsecond details"), - Severity: "NORMAL", - Anchor: BitBucketPendingCommentAnchor{ - Path: "rule.yaml", - Line: 3, - LineType: "ADDED", - FileType: "TO", - DiffType: "EFFECTIVE", - }, - }, - { - Text: "This pint run would create 5 comment(s), which is more than 2 limit configured for pint.\n3 comments were skipped and won't be visible on this PR.", - Severity: "NORMAL", - Anchor: BitBucketPendingCommentAnchor{ - DiffType: "EFFECTIVE", - }, - }, - }, - }, - { - description: "truncate long comments", - maxComments: 2, - summary: Summary{reports: []Report{ - { - Path: discovery.Path{ - SymlinkTarget: "rule.yaml", - Name: "rule.yaml", - }, - Changes: &discovery.Changes{ - OldPath: "", - Lines: git.LineNumbers{ - {Before: 0, After: 2, Modified: true}, - {Before: 0, After: 3, Modified: true}, - }, - }, - Problem: checks.Problem{ - Severity: checks.Bug, - Lines: diags.LineRange{ - First: 2, - Last: 2, - }, - Summary: strings.Repeat("X", maxCommentLength+1), - Reporter: "r1", - }, - }, - }}, - changes: &bitBucketPRChanges{ - pathModifiedLines: map[string][]int{ - "rule.yaml": {2, 3}, - }, - pathLineMapping: map[string]map[int]int{ - "rule.yaml": {2: 2, 3: 3}, - }, - }, - comments: []BitBucketPendingComment{ - { - Text: ":stop_sign: **Bug** reported by [pint](https://cloudflare.github.io/pint/) **r1** check.\n\n------\n\n" + strings.Repeat("X", maxCommentLength-98-4) + " ...", - Severity: "BLOCKER", - Anchor: BitBucketPendingCommentAnchor{ - Path: "rule.yaml", - Line: 2, - LineType: "ADDED", - FileType: "TO", - DiffType: "EFFECTIVE", - }, - }, - }, - }, - } - - for _, tc := range testCases { - t.Run(tc.description, func(t *testing.T) { - slog.SetDefault(slogt.New(t)) - r := NewBitBucketReporter( - "v0.0.0", - "http://bitbucket.example.com", - time.Second, - "token", - "proj", - "repo", - tc.maxComments, - tc.showDuplicates, - nil, - ) - comments := r.api.limitComments(r.api.makeComments(tc.summary, tc.changes)) - if diff := cmp.Diff(tc.comments, comments); diff != "" { - t.Errorf("api.makeComments() returned wrong output (-want +got):\n%s", diff) - return - } - }) - } -} diff --git a/internal/reporter/bitbucket_test.go b/internal/reporter/bitbucket_test.go index e6f421b6..fd6c3c11 100644 --- a/internal/reporter/bitbucket_test.go +++ b/internal/reporter/bitbucket_test.go @@ -1,2046 +1,1181 @@ -package reporter_test +package reporter import ( + "bytes" + "context" + "encoding/json" "errors" - "fmt" + "io" "log/slog" - "net" "net/http" - "os" - "path/filepath" - "strings" + "net/http/httptest" "testing" "time" "github.com/neilotoole/slogt" + "github.com/stretchr/testify/require" "go.nhat.io/httpmock" "github.com/cloudflare/pint/internal/checks" - "github.com/cloudflare/pint/internal/diags" - "github.com/cloudflare/pint/internal/discovery" "github.com/cloudflare/pint/internal/git" - "github.com/cloudflare/pint/internal/parser" - "github.com/cloudflare/pint/internal/reporter" ) -func TestBitBucketReporter(t *testing.T) { - type errorCheck func(err error) error - - type testCaseT struct { - mock httpmock.Mocker - gitCmd git.CommandRunner - errorHandler errorCheck - description string - reports []reporter.Report - showDuplicates bool - } - - p := parser.NewParser(parser.DefaultOptions) - mockFile := p.Parse(strings.NewReader(` -- record: target is down - expr: up == 0 -- record: sum errors - expr: sum(errors) by (job) -`)) - - fakeGit := func(args ...string) ([]byte, error) { - if args[0] == "rev-parse" && args[1] == "--verify" && args[2] == "HEAD" { - return []byte("fake-commit-id"), nil - } - if args[0] == "rev-parse" && args[1] == "--abbrev-ref" && args[2] == "HEAD" { - return []byte("fake-branch"), nil - } - return nil, nil - } +func TestBitBucketReporterDescribe(t *testing.T) { + // Verifies that Describe returns the reporter name. + bb := BitBucketReporter{} + require.Equal(t, "BitBucket", bb.Describe()) +} - diagFile := filepath.Join(t.TempDir(), "diag.txt") - if err := os.WriteFile(diagFile, []byte("- record: target is down\n expr: up == 0\n"), 0o644); err != nil { - t.Fatal(err) - } +func TestBitBucketReporterDestinations(t *testing.T) { + slog.SetDefault(slogt.New(t)) - testCases := []testCaseT{ - { - description: "returns an error on git head failure", + // Verifies that Destinations returns an error when git HEAD fails. + t.Run("git HEAD failure", func(t *testing.T) { + bb := BitBucketReporter{ + api: newBitBucketAPI( + "http://localhost", time.Second, + "token", "proj", "repo", + ), gitCmd: func(args ...string) ([]byte, error) { - if args[0] == "rev-parse" && args[1] == "--verify" && args[2] == "HEAD" { + if args[0] == "rev-parse" && args[1] == "--verify" { return nil, errors.New("git head error") } return nil, nil }, - mock: httpmock.New(func(_ *httpmock.Server) {}), - errorHandler: func(err error) error { - if err != nil && err.Error() == "failed to get HEAD commit: git head error" { - return nil - } - return fmt.Errorf("Expected git head error, got %w", err) - }, - }, - { - description: "returns an error on git branch failure", + } + _, err := bb.Destinations(t.Context()) + require.EqualError(t, err, "failed to get HEAD commit: git head error") + }) + + // Verifies that Destinations returns an error when git branch fails. + t.Run("git branch failure", func(t *testing.T) { + bb := BitBucketReporter{ + api: newBitBucketAPI( + "http://localhost", time.Second, + "token", "proj", "repo", + ), gitCmd: func(args ...string) ([]byte, error) { - if args[0] == "rev-parse" && args[1] == "--verify" && args[2] == "HEAD" { - return []byte("fake-commit-id"), nil + if args[0] == "rev-parse" && args[1] == "--verify" { + return []byte("abc123"), nil } - if args[0] == "rev-parse" && args[1] == "--abbrev-ref" && args[2] == "HEAD" { + if args[0] == "rev-parse" && args[1] == "--abbrev-ref" { return nil, errors.New("git branch error") } return nil, nil }, - mock: httpmock.New(func(s *httpmock.Server) { - s.ExpectDelete("/rest/insights/1.0/projects/proj/repos/repo/commits/fake-commit-id/reports/pint"). - Once() - s.ExpectPut("/rest/insights/1.0/projects/proj/repos/repo/commits/fake-commit-id/reports/pint"). - Once() - }), - errorHandler: func(err error) error { - if err != nil && err.Error() == "failed to get current branch: git branch error" { - return nil + } + _, err := bb.Destinations(t.Context()) + require.EqualError(t, err, "failed to get current branch: git branch error") + }) + + // Verifies that Destinations returns nil when no PR matches. + t.Run("no matching PR", func(t *testing.T) { + prsJSON, _ := json.Marshal(BitBucketPullRequests{ + IsLastPage: true, + Values: []BitBucketPullRequest{}, + }) + + srv := httpmock.New(func(s *httpmock.Server) { + s.ExpectGet("/rest/api/1.0/projects/proj/repos/repo/commits/abc123/pull-requests?start=0"). + ReturnHeader("Content-Type", "application/json"). + Return(string(prsJSON)). + Once() + })(t) + + bb := BitBucketReporter{ + api: newBitBucketAPI( + srv.URL(), time.Second, + "token", "proj", "repo", + ), + gitCmd: func(args ...string) ([]byte, error) { + if args[0] == "rev-parse" && args[1] == "--verify" { + return []byte("abc123"), nil + } + if args[0] == "rev-parse" && args[1] == "--abbrev-ref" { + return []byte("feature"), nil } - return fmt.Errorf("Expected git branch error, got %w", err) + return nil, nil }, - }, - { - description: "returns an error on non-200 HTTP response", - gitCmd: fakeGit, - reports: []reporter.Report{ + } + dsts, err := bb.Destinations(t.Context()) + require.NoError(t, err) + require.Nil(t, dsts) + }) + + // Verifies that Destinations returns the matching PR destination. + t.Run("matching PR found", func(t *testing.T) { + prsJSON, _ := json.Marshal(BitBucketPullRequests{ + IsLastPage: true, + Values: []BitBucketPullRequest{ { - Path: discovery.Path{ - SymlinkTarget: "foo.txt", - Name: "foo.txt", + ID: 42, + Open: true, + FromRef: BitBucketRef{ + ID: "refs/heads/feature", + Commit: "abc123", }, - Changes: &discovery.Changes{ - OldPath: "", - Lines: git.LineNumbers{{Before: 0, After: 2, Modified: true}}, + ToRef: BitBucketRef{ + ID: "refs/heads/main", + Commit: "def456", }, - Rule: mockFile.Groups[0].Rules[0], - Problem: checks.Problem{}, }, }, - mock: httpmock.New(func(s *httpmock.Server) { - s.ExpectDelete("/rest/insights/1.0/projects/proj/repos/repo/commits/fake-commit-id/reports/pint"). - Once() - s.ExpectPut("/rest/insights/1.0/projects/proj/repos/repo/commits/fake-commit-id/reports/pint"). - ReturnCode(http.StatusBadRequest). - Return("Bad Request"). - Once() - }), - errorHandler: func(err error) error { - if err != nil && err.Error() == "failed to create BitBucket report: PUT request failed" { - return nil + }) + + srv := httpmock.New(func(s *httpmock.Server) { + s.ExpectGet("/rest/api/1.0/projects/proj/repos/repo/commits/abc123/pull-requests?start=0"). + ReturnHeader("Content-Type", "application/json"). + Return(string(prsJSON)). + Once() + })(t) + + bb := BitBucketReporter{ + api: newBitBucketAPI( + srv.URL(), time.Second, + "token", "proj", "repo", + ), + gitCmd: func(args ...string) ([]byte, error) { + if args[0] == "rev-parse" && args[1] == "--verify" { + return []byte("abc123"), nil + } + if args[0] == "rev-parse" && args[1] == "--abbrev-ref" { + return []byte("feature"), nil } - return fmt.Errorf("Expected 'failed to create BitBucket report: PUT request failed', got %w", err) + return nil, nil }, - }, - { - description: "returns an error on HTTP response headers timeout", - gitCmd: fakeGit, - reports: []reporter.Report{ + } + dsts, err := bb.Destinations(t.Context()) + require.NoError(t, err) + require.Len(t, dsts, 1) + pr := dsts[0].(*bitBucketPR) + require.Equal(t, 42, pr.ID) + require.Equal(t, "feature", pr.srcBranch) + require.Equal(t, "abc123", pr.srcHead) + require.Equal(t, "main", pr.dstBranch) + require.Equal(t, "def456", pr.dstHead) + }) +} + +func TestBitBucketReporterList(t *testing.T) { + slog.SetDefault(slogt.New(t)) + + // Verifies that List maps BitBucket comments to ExistingComment structs. + t.Run("maps comments correctly", func(t *testing.T) { + activitiesJSON, _ := json.Marshal(BitBucketPullRequestActivities{ + IsLastPage: true, + Values: []BitBucketPullRequestActivity{ { - Path: discovery.Path{ - SymlinkTarget: "foo.txt", - Name: "foo.txt", - }, - Changes: &discovery.Changes{ - OldPath: "", - Lines: git.LineNumbers{{Before: 0, After: 2, Modified: true}}, + Action: "COMMENTED", + CommentAction: "ADDED", + Comment: BitBucketPullRequestComment{ + ID: 10, + Version: 2, + State: "OPEN", + Author: BitBucketCommentAuthor{Name: "testuser"}, + Text: "some comment", + }, + CommentAnchor: BitBucketCommentAnchor{ + Path: "foo.yaml", + Line: 5, }, - Rule: mockFile.Groups[0].Rules[0], - Problem: checks.Problem{}, }, }, - mock: httpmock.New(func(s *httpmock.Server) { - s.ExpectDelete("/rest/insights/1.0/projects/proj/repos/repo/commits/fake-commit-id/reports/pint"). - Once() - s.ExpectPut("/rest/insights/1.0/projects/proj/repos/repo/commits/fake-commit-id/reports/pint"). - Run(func(_ *http.Request) ([]byte, error) { - time.Sleep(time.Second * 2) - return []byte("Bad Request"), nil - }). - ReturnCode(http.StatusBadRequest). - Once() - }), - errorHandler: func(err error) error { - if neterr, ok := errors.AsType[net.Error](errors.Unwrap(err)); ok && neterr.Timeout() { - return nil - } - return fmt.Errorf("Expected a timeout error, got %w", err) - }, - }, + }) + + srv := httpmock.New(func(s *httpmock.Server) { + s.ExpectGet("/plugins/servlet/applinks/whoami"). + Return("testuser"). + Once() + s.ExpectGet("/rest/api/latest/projects/proj/repos/repo/pull-requests/1/activities?start=0"). + ReturnHeader("Content-Type", "application/json"). + Return(string(activitiesJSON)). + Once() + })(t) + + bb := BitBucketReporter{ + api: newBitBucketAPI( + srv.URL(), time.Second, + "token", "proj", "repo", + ), + } + pr := &bitBucketPR{ID: 1} + existing, err := bb.List(t.Context(), pr) + require.NoError(t, err) + require.Len(t, existing, 1) + require.Equal(t, "foo.yaml", existing[0].path) + require.Equal(t, 5, existing[0].line) + require.Equal(t, "some comment", existing[0].text) + meta := existing[0].meta.(bitBucketCommentMeta) + require.Equal(t, 10, meta.id) + require.Equal(t, 2, meta.version) + }) +} + +func TestBitBucketReporterCreate(t *testing.T) { + slog.SetDefault(slogt.New(t)) + + type testCaseT struct { + description string + wantSev string + wantAnchor bitBucketPendingCommentAnchor + pending PendingComment + } + + testCases := []testCaseT{ { - description: "returns an error on HTTP response body timeout", - gitCmd: fakeGit, - reports: []reporter.Report{ - { - Path: discovery.Path{ - SymlinkTarget: "foo.txt", - Name: "foo.txt", - }, - Changes: &discovery.Changes{ - OldPath: "", - Lines: git.LineNumbers{{Before: 0, After: 2, Modified: true}}, - }, - Rule: mockFile.Groups[0].Rules[0], - Problem: checks.Problem{}, + // Line is in changedLines (ADDED) so anchor should be ADDED/TO. + description: "modified line uses ADDED/TO anchor", + pending: PendingComment{ + path: "foo.yaml", + line: 5, + text: ":stop_sign: Bug found", + changedLines: git.LineNumbers{ + {Before: 0, After: 5, Modified: true}, }, }, - mock: httpmock.New(func(s *httpmock.Server) { - s.ExpectDelete("/rest/insights/1.0/projects/proj/repos/repo/commits/fake-commit-id/reports/pint"). - Once() - s.ExpectPut("/rest/insights/1.0/projects/proj/repos/repo/commits/fake-commit-id/reports/pint"). - Run(func(_ *http.Request) ([]byte, error) { - time.Sleep(time.Second * 2) - return []byte("Bad Request"), nil - }). - ReturnCode(http.StatusBadRequest). - Once() - }), - errorHandler: func(err error) error { - if neterr, ok := errors.AsType[net.Error](errors.Unwrap(err)); ok && neterr.Timeout() { - return nil - } - return fmt.Errorf("Expected a timeout error, got %w", err) + wantAnchor: bitBucketPendingCommentAnchor{ + Path: "foo.yaml", + Line: 5, + DiffType: "EFFECTIVE", + LineType: "ADDED", + FileType: "TO", }, + wantSev: "BLOCKER", }, { - description: "sends a correct report that fails", - gitCmd: fakeGit, - mock: httpmock.New(func(s *httpmock.Server) { - s.ExpectDelete("/rest/insights/1.0/projects/proj/repos/repo/commits/fake-commit-id/reports/pint"). - Once() - s.ExpectPut("/rest/insights/1.0/projects/proj/repos/repo/commits/fake-commit-id/reports/pint"). - ReturnCode(http.StatusInternalServerError). - Return("Internal error"). - Once() - }), - errorHandler: func(err error) error { - if err.Error() != "failed to create BitBucket report: PUT request failed" { - return fmt.Errorf("Unpexpected error: %w", err) - } - return nil + // Line is NOT in changedLines so anchor should be CONTEXT/FROM. + description: "unmodified line uses CONTEXT/FROM anchor", + pending: PendingComment{ + path: "foo.yaml", + line: 5, + text: ":warning: Warning found", + changedLines: git.LineNumbers{ + {Before: 0, After: 3, Modified: true}, + }, }, + wantAnchor: bitBucketPendingCommentAnchor{ + Path: "foo.yaml", + Line: 5, + DiffType: "EFFECTIVE", + LineType: "CONTEXT", + FileType: "FROM", + }, + wantSev: "NORMAL", }, { - description: "sends a correct report but fails to delete annotations", - gitCmd: fakeGit, - mock: httpmock.New(func(s *httpmock.Server) { - s.ExpectDelete("/rest/insights/1.0/projects/proj/repos/repo/commits/fake-commit-id/reports/pint"). - Once() - s.ExpectPut("/rest/insights/1.0/projects/proj/repos/repo/commits/fake-commit-id/reports/pint"). - Once() - s.ExpectGet("/rest/api/1.0/projects/proj/repos/repo/commits/fake-commit-id/pull-requests?start=0"). - ReturnJSON(reporter.BitBucketPullRequests{ - IsLastPage: true, - Values: []reporter.BitBucketPullRequest{}, - }). - Once() - s.ExpectDelete("/rest/insights/1.0/projects/proj/repos/repo/commits/fake-commit-id/reports/pint/annotations"). - ReturnCode(http.StatusInternalServerError). - Return("Internal error"). - Once() - }), - errorHandler: func(err error) error { - if err.Error() != "failed to delete existing BitBucket code insight annotations: DELETE request failed" { - return fmt.Errorf("Unpexpected error: %w", err) - } - return nil + // AnchorBefore forces REMOVED lineType. + description: "AnchorBefore uses REMOVED/FROM anchor", + pending: PendingComment{ + path: "foo.yaml", + line: 5, + text: ":stop_sign: Bug found", + anchor: checks.AnchorBefore, + changedLines: git.LineNumbers{ + {Before: 0, After: 5, Modified: true}, + }, + }, + wantAnchor: bitBucketPendingCommentAnchor{ + Path: "foo.yaml", + Line: 5, + DiffType: "EFFECTIVE", + LineType: "REMOVED", + FileType: "FROM", }, + wantSev: "BLOCKER", }, { - description: "sends a correct report but fails to create annotations", - gitCmd: fakeGit, - reports: []reporter.Report{ - { - Path: discovery.Path{ - SymlinkTarget: "foo.txt", - Name: "foo.txt", - }, - Changes: &discovery.Changes{ - OldPath: "", - Lines: []git.LineNumber{ - {Before: 0, After: 2, Modified: true}, - {Before: 0, After: 4, Modified: true}, - }, - }, - Rule: mockFile.Groups[0].Rules[1], - Problem: checks.Problem{ - Lines: diags.LineRange{ - First: 1, - Last: 1, - }, - Reporter: "mock", - Summary: "this should be ignored, line is not part of the diff", - Severity: checks.Bug, - }, - }, - { - Path: discovery.Path{ - SymlinkTarget: "bar.txt", - Name: "bar.txt", - }, - Changes: &discovery.Changes{ - OldPath: "", - Lines: git.LineNumbers{}, - }, - Rule: mockFile.Groups[0].Rules[1], - Problem: checks.Problem{ - Lines: diags.LineRange{ - First: 1, - Last: 1, - }, - Reporter: "mock", - Summary: "this should be ignored, file is not part of the diff", - Severity: checks.Bug, - }, - }, - { - Path: discovery.Path{ - SymlinkTarget: "foo.txt", - Name: "foo.txt", - }, - Changes: &discovery.Changes{ - OldPath: "", - Lines: []git.LineNumber{ - {Before: 0, After: 2, Modified: true}, - {Before: 0, After: 4, Modified: true}, - }, - }, - Rule: mockFile.Groups[0].Rules[1], - Problem: checks.Problem{ - Lines: diags.LineRange{ - First: 2, - Last: 2, - }, - Reporter: "mock", - Summary: "bad name", - Severity: checks.Fatal, - }, - }, - { - Path: discovery.Path{ - SymlinkTarget: "foo.txt", - Name: "foo.txt", - }, - Changes: &discovery.Changes{ - OldPath: "", - Lines: []git.LineNumber{ - {Before: 0, After: 2, Modified: true}, - {Before: 0, After: 4, Modified: true}, - }, - }, - Rule: mockFile.Groups[0].Rules[0], - Problem: checks.Problem{ - Lines: diags.LineRange{ - First: 2, - Last: 2, - }, - Reporter: "mock", - Summary: "mock text", - Severity: checks.Bug, - }, - }, - { - Path: discovery.Path{ - SymlinkTarget: "foo.txt", - Name: "foo.txt", - }, - Changes: &discovery.Changes{ - OldPath: "", - Lines: []git.LineNumber{ - {Before: 0, After: 2, Modified: true}, - {Before: 0, After: 4, Modified: true}, - }, - }, - Rule: mockFile.Groups[0].Rules[1], - Problem: checks.Problem{ - Lines: diags.LineRange{ - First: 4, - Last: 4, - }, - Reporter: "mock", - Summary: "mock text 2", - Severity: checks.Warning, - }, + // Unmodified line with line mapping remaps the line number. + description: "unmodified line uses BeforeForAfter mapping", + pending: PendingComment{ + path: "foo.yaml", + line: 5, + text: ":warning: Warning found", + changedLines: git.LineNumbers{ + {Before: 3, After: 5, Modified: false}, }, }, - mock: httpmock.New(func(s *httpmock.Server) { - s.ExpectDelete("/rest/insights/1.0/projects/proj/repos/repo/commits/fake-commit-id/reports/pint"). - Once() - s.ExpectPut("/rest/insights/1.0/projects/proj/repos/repo/commits/fake-commit-id/reports/pint"). - Once() - s.ExpectGet("/rest/api/1.0/projects/proj/repos/repo/commits/fake-commit-id/pull-requests?start=0"). - ReturnJSON(reporter.BitBucketPullRequests{ - IsLastPage: true, - Values: []reporter.BitBucketPullRequest{}, - }). - Once() - s.ExpectDelete("/rest/insights/1.0/projects/proj/repos/repo/commits/fake-commit-id/reports/pint/annotations"). - Once() - s.ExpectPost("/rest/insights/1.0/projects/proj/repos/repo/commits/fake-commit-id/reports/pint/annotations"). - ReturnCode(http.StatusInternalServerError). - Return("Internal error"). - Once() - }), - errorHandler: func(err error) error { - if err.Error() != "failed to create BitBucket code insight annotations: POST request failed" { - return fmt.Errorf("Unpexpected error: %w", err) - } - return nil + wantAnchor: bitBucketPendingCommentAnchor{ + Path: "foo.yaml", + Line: 3, + DiffType: "EFFECTIVE", + LineType: "CONTEXT", + FileType: "FROM", }, + wantSev: "NORMAL", }, - { - description: "pull requests get fails", - gitCmd: fakeGit, - mock: httpmock.New(func(s *httpmock.Server) { - s.ExpectDelete("/rest/insights/1.0/projects/proj/repos/repo/commits/fake-commit-id/reports/pint"). - Once() - s.ExpectPut("/rest/insights/1.0/projects/proj/repos/repo/commits/fake-commit-id/reports/pint"). - Once() - s.ExpectGet("/rest/api/1.0/projects/proj/repos/repo/commits/fake-commit-id/pull-requests?start=0"). - ReturnCode(http.StatusInternalServerError). - Return("Internal error"). + } + + for _, tc := range testCases { + t.Run(tc.description, func(t *testing.T) { + srv := httpmock.New(func(s *httpmock.Server) { + s.ExpectPost( + "/rest/api/1.0/projects/proj/repos/repo/pull-requests/1/comments", + ). + Return("{}"). Once() - }), - errorHandler: func(err error) error { - if err.Error() != "failed to get open pull requests from BitBucket: GET request failed" { - return fmt.Errorf("Unpexpected error: %w", err) - } - return nil - }, + })(t) + + bb := BitBucketReporter{ + api: newBitBucketAPI( + srv.URL(), time.Second, + "token", "proj", "repo", + ), + } + pr := &bitBucketPR{ID: 1} + err := bb.Create(t.Context(), pr, tc.pending) + require.NoError(t, err) + require.Len(t, srv.Requests, 1) + require.Equal(t, http.MethodPost, srv.Requests[0].Method()) + }) + } +} + +func TestBitBucketReporterDelete(t *testing.T) { + slog.SetDefault(slogt.New(t)) + + // Verifies that Delete sends a DELETE request with correct comment ID and version. + srv := httpmock.New(func(s *httpmock.Server) { + s.ExpectDelete( + "/rest/api/1.0/projects/proj/repos/repo/pull-requests/1/comments/10?version=2", + ). + Once() + })(t) + + bb := BitBucketReporter{ + api: newBitBucketAPI( + srv.URL(), time.Second, + "token", "proj", "repo", + ), + } + pr := &bitBucketPR{ID: 1} + err := bb.Delete(t.Context(), pr, ExistingComment{ + path: "foo.yaml", + line: 5, + text: "old comment", + meta: bitBucketCommentMeta{id: 10, version: 2}, + }) + require.NoError(t, err) + require.Len(t, srv.Requests, 1) + require.Equal(t, http.MethodDelete, srv.Requests[0].Method()) +} + +func TestBitBucketReporterCanCreate(t *testing.T) { + type testCaseT struct { + description string + maxComments int + done int + expected bool + } + + testCases := []testCaseT{ + { + // Under the limit should allow creation. + description: "under limit", + maxComments: 10, + done: 5, + expected: true, }, { - description: "pull request changes get fails", - gitCmd: fakeGit, - mock: httpmock.New(func(s *httpmock.Server) { - s.ExpectDelete("/rest/insights/1.0/projects/proj/repos/repo/commits/fake-commit-id/reports/pint"). - Once() - s.ExpectPut("/rest/insights/1.0/projects/proj/repos/repo/commits/fake-commit-id/reports/pint"). - Once() - s.ExpectGet("/rest/api/1.0/projects/proj/repos/repo/commits/fake-commit-id/pull-requests?start=0"). - ReturnJSON(reporter.BitBucketPullRequests{ - IsLastPage: true, - Values: []reporter.BitBucketPullRequest{ - { - ID: 102, - Open: true, - FromRef: reporter.BitBucketRef{ - ID: "refs/heads/fake-branch", - Commit: "fake-commit-id", - }, - ToRef: reporter.BitBucketRef{ - ID: "refs/heads/main", - Commit: "main-commit-id", - }, - }, - }, - }). - Once() - s.ExpectGet("/rest/api/1.0/projects/proj/repos/repo/pull-requests/102/changes?start=0"). - ReturnCode(http.StatusInternalServerError). - Return("Internal error"). - Once() - }), - errorHandler: func(err error) error { - if err.Error() != "failed to get pull request changes from BitBucket: GET request failed" { - return fmt.Errorf("Unpexpected error: %w", err) - } - return nil - }, + // At the limit should not allow creation. + description: "at limit", + maxComments: 10, + done: 10, + expected: false, }, { - description: "pull request comments get fails", - gitCmd: fakeGit, - mock: httpmock.New(func(s *httpmock.Server) { - s.ExpectDelete("/rest/insights/1.0/projects/proj/repos/repo/commits/fake-commit-id/reports/pint"). - Once() - s.ExpectPut("/rest/insights/1.0/projects/proj/repos/repo/commits/fake-commit-id/reports/pint"). - Once() - s.ExpectGet("/rest/api/1.0/projects/proj/repos/repo/commits/fake-commit-id/pull-requests?start=0"). - ReturnJSON(reporter.BitBucketPullRequests{ - IsLastPage: true, - Values: []reporter.BitBucketPullRequest{ - { - ID: 102, - Open: true, - FromRef: reporter.BitBucketRef{ - ID: "refs/heads/fake-branch", - Commit: "fake-commit-id", - }, - ToRef: reporter.BitBucketRef{ - ID: "refs/heads/main", - Commit: "main-commit-id", - }, - }, - }, - }). - Once() - s.ExpectGet("/rest/api/1.0/projects/proj/repos/repo/pull-requests/102/changes?start=0"). - ReturnJSON(reporter.BitBucketPullRequestChanges{ - IsLastPage: true, - Values: []reporter.BitBucketPullRequestChange{}, - }). - Once() - s.ExpectGet("/plugins/servlet/applinks/whoami"). - Return("testuser"). - Once() - s.ExpectGet("/rest/api/latest/projects/proj/repos/repo/pull-requests/102/activities?start=0"). - ReturnCode(http.StatusInternalServerError). - Return("Internal error"). - Once() - }), - errorHandler: func(err error) error { - if err.Error() != "failed to get pull request comments from BitBucket: GET request failed" { - return fmt.Errorf("Unpexpected error: %w", err) - } - return nil - }, + // Over the limit should not allow creation. + description: "over limit", + maxComments: 10, + done: 15, + expected: false, }, + } + + for _, tc := range testCases { + t.Run(tc.description, func(t *testing.T) { + bb := BitBucketReporter{maxComments: tc.maxComments} + require.Equal(t, tc.expected, bb.CanCreate(tc.done)) + }) + } +} + +func TestBitBucketReporterCanDelete(t *testing.T) { + // Verifies that CanDelete always returns true. + bb := BitBucketReporter{} + require.True(t, bb.CanDelete(ExistingComment{})) +} + +func TestBitBucketReporterIsEqual(t *testing.T) { + slog.SetDefault(slogt.New(t)) + + type testCaseT struct { + description string + existing ExistingComment + pending PendingComment + expected bool + } + + testCases := []testCaseT{ { - description: "sends a correct report", - gitCmd: fakeGit, - mock: httpmock.New(func(s *httpmock.Server) { - s.ExpectDelete("/rest/insights/1.0/projects/proj/repos/repo/commits/fake-commit-id/reports/pint"). - Once() - s.ExpectPut("/rest/insights/1.0/projects/proj/repos/repo/commits/fake-commit-id/reports/pint"). - Once() - s.ExpectGet("/rest/api/1.0/projects/proj/repos/repo/commits/fake-commit-id/pull-requests?start=0"). - ReturnJSON(reporter.BitBucketPullRequests{IsLastPage: true}). - Once() - s.ExpectDelete("/rest/insights/1.0/projects/proj/repos/repo/commits/fake-commit-id/reports/pint/annotations"). - Once() - s.ExpectPost("/rest/insights/1.0/projects/proj/repos/repo/commits/fake-commit-id/reports/pint/annotations"). - Once() - }), - reports: []reporter.Report{ - { - Path: discovery.Path{ - SymlinkTarget: "foo.txt", - Name: "foo.txt", - }, - Changes: &discovery.Changes{ - OldPath: "", - Lines: []git.LineNumber{ - {Before: 0, After: 2, Modified: true}, - {Before: 0, After: 4, Modified: true}, - }, - }, - Rule: mockFile.Groups[0].Rules[1], - Problem: checks.Problem{ - Lines: diags.LineRange{ - First: 1, - Last: 1, - }, - Reporter: "mock", - Summary: "line is not part of the diff", - Severity: checks.Bug, - }, - }, - { - Path: discovery.Path{ - SymlinkTarget: "foo.txt", - Name: "foo.txt", - }, - Changes: &discovery.Changes{ - OldPath: "", - Lines: []git.LineNumber{ - {Before: 0, After: 2, Modified: true}, - {Before: 0, After: 4, Modified: true}, - }, - }, - Rule: mockFile.Groups[0].Rules[1], - Problem: checks.Problem{ - Lines: diags.LineRange{ - First: 2, - Last: 2, - }, - Reporter: "mock", - Summary: "bad name", - Severity: checks.Fatal, - }, - }, - { - Path: discovery.Path{ - SymlinkTarget: "foo.txt", - Name: "foo.txt", - }, - Changes: &discovery.Changes{ - OldPath: "", - Lines: []git.LineNumber{ - {Before: 0, After: 2, Modified: true}, - {Before: 0, After: 4, Modified: true}, - }, - }, - Rule: mockFile.Groups[0].Rules[0], - Problem: checks.Problem{ - Lines: diags.LineRange{ - First: 2, - Last: 2, - }, - Reporter: "mock", - Summary: "mock text", - Severity: checks.Bug, - }, - }, - { - Path: discovery.Path{ - SymlinkTarget: "foo.txt", - Name: "foo.txt", - }, - Changes: &discovery.Changes{ - OldPath: "", - Lines: []git.LineNumber{ - {Before: 0, After: 2, Modified: true}, - {Before: 0, After: 4, Modified: true}, - }, - }, - Rule: mockFile.Groups[0].Rules[1], - Problem: checks.Problem{ - Lines: diags.LineRange{ - First: 4, - Last: 4, - }, - Reporter: "mock", - Summary: "mock text 2", - Severity: checks.Warning, - }, - }, + // Same path, line, and text should be equal. + description: "all fields match", + existing: ExistingComment{ + path: "foo.yaml", + line: 10, + text: "comment text", }, - errorHandler: func(err error) error { - if err.Error() != "fatal error(s) reported" { - return fmt.Errorf("Unpexpected error: %w", err) - } - return nil + pending: PendingComment{ + path: "foo.yaml", + line: 10, + text: "comment text", }, + expected: true, }, { - description: "FATAL errors are always reported, regardless of line number", - gitCmd: fakeGit, - mock: httpmock.New(func(s *httpmock.Server) { - s.ExpectDelete("/rest/insights/1.0/projects/proj/repos/repo/commits/fake-commit-id/reports/pint"). - Once() - s.ExpectPut("/rest/insights/1.0/projects/proj/repos/repo/commits/fake-commit-id/reports/pint"). - Once() - s.ExpectGet("/rest/api/1.0/projects/proj/repos/repo/commits/fake-commit-id/pull-requests?start=0"). - ReturnJSON(reporter.BitBucketPullRequests{IsLastPage: true}). - Once() - s.ExpectDelete("/rest/insights/1.0/projects/proj/repos/repo/commits/fake-commit-id/reports/pint/annotations"). - Once() - s.ExpectPost("/rest/insights/1.0/projects/proj/repos/repo/commits/fake-commit-id/reports/pint/annotations"). - Once() - }), - reports: []reporter.Report{ - { - Path: discovery.Path{ - SymlinkTarget: "foo.txt", - Name: "foo.txt", - }, - Changes: &discovery.Changes{ - OldPath: "", - Lines: git.LineNumbers{ - {Before: 0, After: 3, Modified: true}, - {Before: 0, After: 4, Modified: true}, - }, - }, - Rule: mockFile.Groups[0].Rules[1], - Problem: checks.Problem{ - Lines: diags.LineRange{ - First: 1, - Last: 1, - }, - Reporter: "test/mock", - Summary: "syntax error", - Severity: checks.Fatal, - }, - }, + // Different path should not be equal. + description: "different path", + existing: ExistingComment{ + path: "foo.yaml", + line: 10, + text: "comment text", }, - errorHandler: func(err error) error { - if err.Error() != "fatal error(s) reported" { - return fmt.Errorf("Unpexpected error: %w", err) - } - return nil + pending: PendingComment{ + path: "bar.yaml", + line: 10, + text: "comment text", }, + expected: false, }, { - // Covers bitbucket.go:49-51 — deleteReport error is logged but does not stop the flow. - description: "deleteReport fails but flow continues", - gitCmd: fakeGit, - mock: httpmock.New(func(s *httpmock.Server) { - s.ExpectDelete("/rest/insights/1.0/projects/proj/repos/repo/commits/fake-commit-id/reports/pint"). - ReturnCode(http.StatusInternalServerError). - Once() - s.ExpectPut("/rest/insights/1.0/projects/proj/repos/repo/commits/fake-commit-id/reports/pint"). - Once() - s.ExpectGet("/rest/api/1.0/projects/proj/repos/repo/commits/fake-commit-id/pull-requests?start=0"). - ReturnJSON(reporter.BitBucketPullRequests{IsLastPage: true}). - Once() - s.ExpectDelete("/rest/insights/1.0/projects/proj/repos/repo/commits/fake-commit-id/reports/pint/annotations"). - Once() - }), - errorHandler: func(err error) error { - if err != nil { - return fmt.Errorf("Unpexpected error: %w", err) - } - return nil + // Different line should not be equal. + description: "different line", + existing: ExistingComment{ + path: "foo.yaml", + line: 10, + text: "comment text", }, + pending: PendingComment{ + path: "foo.yaml", + line: 20, + text: "comment text", + }, + expected: false, }, { - description: "sends a correct empty report", - gitCmd: fakeGit, - mock: httpmock.New(func(s *httpmock.Server) { - s.ExpectDelete("/rest/insights/1.0/projects/proj/repos/repo/commits/fake-commit-id/reports/pint"). - Once() - s.ExpectPut("/rest/insights/1.0/projects/proj/repos/repo/commits/fake-commit-id/reports/pint"). - Once() - s.ExpectGet("/rest/api/1.0/projects/proj/repos/repo/commits/fake-commit-id/pull-requests?start=0"). - ReturnJSON(reporter.BitBucketPullRequests{IsLastPage: true}). - Once() - s.ExpectDelete("/rest/insights/1.0/projects/proj/repos/repo/commits/fake-commit-id/reports/pint/annotations"). - Once() - }), - errorHandler: func(err error) error { - if err != nil { - return fmt.Errorf("Unpexpected error: %w", err) - } - return nil + // Different text should not be equal. + description: "different text", + existing: ExistingComment{ + path: "foo.yaml", + line: 10, + text: "comment A", }, + pending: PendingComment{ + path: "foo.yaml", + line: 10, + text: "comment B", + }, + expected: false, }, { - description: "reports failures from unmodified lines", - gitCmd: fakeGit, - mock: httpmock.New(func(s *httpmock.Server) { - s.ExpectDelete("/rest/insights/1.0/projects/proj/repos/repo/commits/fake-commit-id/reports/pint"). - Once() - s.ExpectPut("/rest/insights/1.0/projects/proj/repos/repo/commits/fake-commit-id/reports/pint"). - Once() - s.ExpectGet("/rest/api/1.0/projects/proj/repos/repo/commits/fake-commit-id/pull-requests?start=0"). - ReturnJSON(reporter.BitBucketPullRequests{IsLastPage: true}). - Once() - s.ExpectDelete("/rest/insights/1.0/projects/proj/repos/repo/commits/fake-commit-id/reports/pint/annotations"). - Once() - s.ExpectPost("/rest/insights/1.0/projects/proj/repos/repo/commits/fake-commit-id/reports/pint/annotations"). - Once() - }), - reports: []reporter.Report{ + // Trailing newline should be stripped before comparison. + description: "trailing newline is ignored", + existing: ExistingComment{ + path: "foo.yaml", + line: 10, + text: "comment text\n", + }, + pending: PendingComment{ + path: "foo.yaml", + line: 10, + text: "comment text", + }, + expected: true, + }, + } + + bb := BitBucketReporter{} + for _, tc := range testCases { + t.Run(tc.description, func(t *testing.T) { + result := bb.IsEqual(nil, tc.existing, tc.pending) + require.Equal(t, tc.expected, result) + }) + } +} + +func TestBitBucketAPIRequest(t *testing.T) { + slog.SetDefault(slogt.New(t)) + + t.Run("successful request with body", func(t *testing.T) { + srv := httpmock.New(func(s *httpmock.Server) { + s.ExpectPost("/test"). + WithHeader("Content-Type", "application/json"). + WithHeader("Authorization", "Bearer test-token"). + Return(`{"result": "ok"}`). + Once() + })(t) + + bb := bitBucketAPI{ + uri: srv.URL(), + authToken: "test-token", + timeout: time.Second * 5, + } + + body := bytes.NewReader([]byte(`{"key": "value"}`)) + resp, err := bb.request(http.MethodPost, "/test", body) + require.NoError(t, err) + require.JSONEq(t, `{"result": "ok"}`, string(resp)) + }) + + t.Run("request with invalid URL", func(t *testing.T) { + bb := bitBucketAPI{ + uri: "://invalid-url", + authToken: "test-token", + timeout: time.Second, + } + + _, err := bb.request(http.MethodGet, "/test", nil) + require.Error(t, err) + }) + + t.Run("non-2xx response returns error", func(t *testing.T) { + srv := httpmock.New(func(s *httpmock.Server) { + s.ExpectGet("/test"). + ReturnCode(http.StatusBadRequest). + Return("Bad Request"). + Once() + })(t) + + bb := bitBucketAPI{ + uri: srv.URL(), + authToken: "test-token", + timeout: time.Second * 5, + } + + _, err := bb.request(http.MethodGet, "/test", nil) + require.Error(t, err) + require.Equal(t, "GET request failed", err.Error()) + }) +} + +func TestBitBucketAPIGetPullRequestComments(t *testing.T) { + slog.SetDefault(slogt.New(t)) + + t.Run("filters comments correctly", func(t *testing.T) { + activities := BitBucketPullRequestActivities{ + IsLastPage: true, + Values: []BitBucketPullRequestActivity{ { - Path: discovery.Path{ - SymlinkTarget: "foo.txt", - Name: "foo.txt", - }, - Rule: mockFile.Groups[0].Rules[1], - Changes: &discovery.Changes{ - OldPath: "", - Lines: []git.LineNumber{ - {Before: 0, After: 2, Modified: true}, - {Before: 0, After: 4, Modified: true}, - }, - }, - Problem: checks.Problem{ - Lines: diags.LineRange{ - First: 1, - Last: 1, - }, - Reporter: "mock", - Summary: "this line is not part of the diff", - Severity: checks.Bug, - }, + Action: "COMMENTED", + CommentAction: "ADDED", + Comment: BitBucketPullRequestComment{ + ID: 1, + Version: 1, + State: "OPEN", + Author: BitBucketCommentAuthor{Name: "testuser"}, + Text: "valid comment", + }, + CommentAnchor: BitBucketCommentAnchor{Path: "foo.yaml", Line: 10}, }, { - Path: discovery.Path{ - SymlinkTarget: "foo.txt", - Name: "foo.txt", - }, - Rule: mockFile.Groups[0].Rules[1], - Changes: &discovery.Changes{ - OldPath: "", - Lines: []git.LineNumber{ - {Before: 0, After: 2, Modified: true}, - {Before: 0, After: 4, Modified: true}, - }, - }, - Problem: checks.Problem{ - Lines: diags.LineRange{ - First: 2, - Last: 2, - }, - Reporter: "mock", - Summary: "bad name", - Severity: checks.Bug, + Action: "APPROVED", + CommentAction: "ADDED", + Comment: BitBucketPullRequestComment{ + ID: 2, + State: "OPEN", + Author: BitBucketCommentAuthor{Name: "testuser"}, }, }, { - Path: discovery.Path{ - SymlinkTarget: "foo.txt", - Name: "foo.txt", - }, - Rule: mockFile.Groups[0].Rules[0], - Changes: &discovery.Changes{ - OldPath: "", - Lines: []git.LineNumber{ - {Before: 0, After: 2, Modified: true}, - {Before: 0, After: 4, Modified: true}, - }, - }, - Problem: checks.Problem{ - Lines: diags.LineRange{ - First: 2, - Last: 2, - }, - Reporter: "mock", - Summary: "mock text", - Severity: checks.Bug, + Action: "COMMENTED", + CommentAction: "EDITED", + Comment: BitBucketPullRequestComment{ + ID: 3, + State: "OPEN", + Author: BitBucketCommentAuthor{Name: "testuser"}, }, }, { - Path: discovery.Path{ - SymlinkTarget: "foo.txt", - Name: "foo.txt", + Action: "COMMENTED", + CommentAction: "ADDED", + Comment: BitBucketPullRequestComment{ + ID: 4, + State: "RESOLVED", + Author: BitBucketCommentAuthor{Name: "testuser"}, }, - Rule: mockFile.Groups[0].Rules[1], - Changes: &discovery.Changes{ - OldPath: "", - Lines: []git.LineNumber{ - {Before: 0, After: 2, Modified: true}, - {Before: 0, After: 4, Modified: true}, - }, + }, + { + Action: "COMMENTED", + CommentAction: "ADDED", + Comment: BitBucketPullRequestComment{ + ID: 5, + State: "OPEN", + Author: BitBucketCommentAuthor{Name: "otheruser"}, }, - Problem: checks.Problem{ - Lines: diags.LineRange{ - First: 4, - Last: 4, - }, - Reporter: "mock", - Summary: "mock text 2", - Severity: checks.Warning, + }, + { + Action: "COMMENTED", + CommentAction: "ADDED", + Comment: BitBucketPullRequestComment{ + ID: 6, + State: "OPEN", + Author: BitBucketCommentAuthor{Name: "testuser"}, + Severity: "BLOCKER", + Resolved: true, }, }, + { + Action: "COMMENTED", + CommentAction: "ADDED", + Comment: BitBucketPullRequestComment{ + ID: 7, + State: "OPEN", + Author: BitBucketCommentAuthor{Name: "testuser"}, + Severity: "NORMAL", + }, + CommentAnchor: BitBucketCommentAnchor{Orphaned: true}, + }, }, - errorHandler: func(err error) error { - if err != nil { - return fmt.Errorf("Unpexpected error: %w", err) - } - return nil - }, - }, - { - description: "sends a correct report with pull request open", - gitCmd: fakeGit, - mock: httpmock.New(func(s *httpmock.Server) { - s.ExpectDelete("/rest/insights/1.0/projects/proj/repos/repo/commits/fake-commit-id/reports/pint"). - Once() - s.ExpectPut("/rest/insights/1.0/projects/proj/repos/repo/commits/fake-commit-id/reports/pint"). - Once() - s.ExpectGet("/rest/api/1.0/projects/proj/repos/repo/commits/fake-commit-id/pull-requests?start=0"). - ReturnJSON(reporter.BitBucketPullRequests{ - IsLastPage: true, - Values: []reporter.BitBucketPullRequest{ - { - ID: 101, - Open: false, - FromRef: reporter.BitBucketRef{ - ID: "refs/heads/feature", - Commit: "pr-commit-id", - }, - ToRef: reporter.BitBucketRef{ - ID: "refs/heads/main", - Commit: "main-commit-id", - }, - }, - { - ID: 102, - Open: true, - FromRef: reporter.BitBucketRef{ - ID: "refs/heads/fake-branch", - Commit: "fake-commit-id", - }, - ToRef: reporter.BitBucketRef{ - ID: "refs/heads/main", - Commit: "main-commit-id", - }, - }, - }, - }). - Once() - s.ExpectGet("/rest/api/1.0/projects/proj/repos/repo/pull-requests/102/changes?start=0"). - ReturnJSON(reporter.BitBucketPullRequestChanges{IsLastPage: true}). - Once() - s.ExpectGet("/plugins/servlet/applinks/whoami"). - Return("pint_user"). - Once() - s.ExpectGet("/rest/api/latest/projects/proj/repos/repo/pull-requests/102/activities?start=0"). - ReturnJSON(reporter.BitBucketPullRequestActivities{IsLastPage: true}). - Once() - }), - errorHandler: func(err error) error { - if err != nil { - return fmt.Errorf("Unpexpected error: %w", err) - } - return nil - }, - }, - { - description: "sends a correct report using comments, deleting stale ones", - gitCmd: fakeGit, - mock: httpmock.New(func(s *httpmock.Server) { - s.ExpectDelete("/rest/insights/1.0/projects/proj/repos/repo/commits/fake-commit-id/reports/pint"). - Once() - s.ExpectPut("/rest/insights/1.0/projects/proj/repos/repo/commits/fake-commit-id/reports/pint"). - Once() - s.ExpectGet("/rest/api/1.0/projects/proj/repos/repo/commits/fake-commit-id/pull-requests?start=0"). - ReturnJSON(reporter.BitBucketPullRequests{ - IsLastPage: true, - Values: []reporter.BitBucketPullRequest{ - { - ID: 102, - Open: true, - FromRef: reporter.BitBucketRef{ - ID: "refs/heads/fake-branch", - Commit: "fake-commit-id", - }, - ToRef: reporter.BitBucketRef{ - ID: "refs/heads/main", - Commit: "main-commit-id", - }, - }, - }, - }). - Once() - s.ExpectGet("/rest/api/1.0/projects/proj/repos/repo/pull-requests/102/changes?start=0"). - ReturnJSON(reporter.BitBucketPullRequestChanges{ - IsLastPage: true, - Values: []reporter.BitBucketPullRequestChange{ - {Path: reporter.BitBucketPath{ToString: "index.txt"}}, - {Path: reporter.BitBucketPath{ToString: "foo.txt"}}, - }, - }). - Once() - s.ExpectGet("/rest/api/latest/projects/proj/repos/repo/commits/fake-commit-id/diff/index.txt?contextLines=10000&since=main-commit-id&whitespace=show&withComments=false"). - ReturnJSON(reporter.BitBucketFileDiffs{ - Diffs: []reporter.BitBucketFileDiff{ - { - Hunks: []reporter.BitBucketDiffHunk{ - { - Segments: []reporter.BitBucketDiffSegment{ - { - Type: "ADDED", - Lines: []reporter.BitBucketDiffLine{ - {Source: 1, Destination: 1}, - {Source: 5, Destination: 5}, - }, - }, - { - Type: "CONTEXT", - Lines: []reporter.BitBucketDiffLine{ - {Source: 10, Destination: 6}, - }, - }, - }, - }, - }, - }, - }, - }). - Once() - s.ExpectGet("/rest/api/latest/projects/proj/repos/repo/commits/fake-commit-id/diff/foo.txt?contextLines=10000&since=main-commit-id&whitespace=show&withComments=false"). - ReturnJSON(reporter.BitBucketFileDiffs{ - Diffs: []reporter.BitBucketFileDiff{ - { - Hunks: []reporter.BitBucketDiffHunk{ - { - Segments: []reporter.BitBucketDiffSegment{ - { - Type: "ADDED", - Lines: []reporter.BitBucketDiffLine{ - {Source: 2, Destination: 2}, - }, - }, - }, - }, - }, - }, - { - Hunks: []reporter.BitBucketDiffHunk{ - { - Segments: []reporter.BitBucketDiffSegment{ - { - Type: "MODIFIED", - Lines: []reporter.BitBucketDiffLine{ - {Source: 3, Destination: 4}, - }, - }, - }, - }, - }, - }, - }, - }). - Once() - s.ExpectGet("/plugins/servlet/applinks/whoami"). - Return("pint_user"). - Once() - s.ExpectGet("/rest/api/latest/projects/proj/repos/repo/pull-requests/102/activities?start=0"). - ReturnJSON(reporter.BitBucketPullRequestActivities{ - IsLastPage: true, - Values: []reporter.BitBucketPullRequestActivity{ - {Action: "APPROVED"}, - { - Action: "COMMENTED", - CommentAction: "ADDED", - CommentAnchor: reporter.BitBucketCommentAnchor{ - Orphaned: true, - LineType: "CONTEXT", - DiffType: "EFFECTIVE", - Path: "foo.txt", - Line: 3, - }, - Comment: reporter.BitBucketPullRequestComment{ - ID: 1001, - Version: 0, - State: "OPEN", - Author: reporter.BitBucketCommentAuthor{Name: "pint_user"}, - }, - }, - { - Action: "COMMENTED", - CommentAction: "ADDED", - CommentAnchor: reporter.BitBucketCommentAnchor{ - Orphaned: true, - DiffType: "COMMIT", - Path: "foo.txt", - Line: 10, - }, - Comment: reporter.BitBucketPullRequestComment{ - ID: 1002, - Version: 1, - State: "OPEN", - Author: reporter.BitBucketCommentAuthor{Name: "pint_user"}, - }, - }, - { - Action: "COMMENTED", - CommentAction: "ADDED", - CommentAnchor: reporter.BitBucketCommentAnchor{ - Orphaned: true, - LineType: "REMOVED", - DiffType: "COMMIT", - Path: "foo.txt", - Line: 14, - }, - Comment: reporter.BitBucketPullRequestComment{ - ID: 1003, - Version: 1, - State: "OPEN", - Severity: "BLOCKER", - Author: reporter.BitBucketCommentAuthor{Name: "pint_user"}, - }, - }, - { - Action: "COMMENTED", - CommentAction: "ADDED", - CommentAnchor: reporter.BitBucketCommentAnchor{ - Orphaned: false, - DiffType: "EFFECTIVE", - Path: "foo.txt", - Line: 3, - }, - Comment: reporter.BitBucketPullRequestComment{ - ID: 2001, - Version: 0, - State: "OPEN", - Author: reporter.BitBucketCommentAuthor{Name: "pint_user"}, - }, - }, - { - Action: "COMMENTED", - CommentAction: "ADDED", - CommentAnchor: reporter.BitBucketCommentAnchor{ - Orphaned: false, - DiffType: "COMMIT", - Path: "foo.txt", - Line: 4, - }, - Comment: reporter.BitBucketPullRequestComment{ - ID: 2002, - Version: 1, - State: "OPEN", - Author: reporter.BitBucketCommentAuthor{Name: "pint_user"}, - }, - }, - }, - }). - Once() - // pruneComments deletes stale comments. - s.ExpectDelete("/rest/api/1.0/projects/proj/repos/repo/pull-requests/102/comments/1001?version=0"). - Once() - s.ExpectDelete("/rest/api/1.0/projects/proj/repos/repo/pull-requests/102/comments/1002?version=1"). - Once() - // 1003 has 0 replies -> deleteComment. - s.ExpectDelete("/rest/api/1.0/projects/proj/repos/repo/pull-requests/102/comments/1003?version=1"). - Once() - s.ExpectDelete("/rest/api/1.0/projects/proj/repos/repo/pull-requests/102/comments/2001?version=0"). - Once() - s.ExpectDelete("/rest/api/1.0/projects/proj/repos/repo/pull-requests/102/comments/2002?version=1"). - Once() - // addComments posts 4 new comments. - s.ExpectPost("/rest/api/1.0/projects/proj/repos/repo/pull-requests/102/comments"). - Once() - s.ExpectPost("/rest/api/1.0/projects/proj/repos/repo/pull-requests/102/comments"). - Once() - s.ExpectPost("/rest/api/1.0/projects/proj/repos/repo/pull-requests/102/comments"). - Once() - s.ExpectPost("/rest/api/1.0/projects/proj/repos/repo/pull-requests/102/comments"). - Once() - }), - reports: []reporter.Report{ - { - Path: discovery.Path{ - SymlinkTarget: "foo.txt", - Name: "foo.txt", - }, - Changes: &discovery.Changes{ - OldPath: "", - Lines: []git.LineNumber{ - {Before: 0, After: 2, Modified: true}, - {Before: 0, After: 4, Modified: true}, - }, - }, - Rule: mockFile.Groups[0].Rules[1], - Problem: checks.Problem{ - Lines: diags.LineRange{ - First: 1, - Last: 1, - }, - Reporter: "mock", - Summary: "this should be ignored, line is not part of the diff", - Severity: checks.Bug, - }, - }, - { - Path: discovery.Path{ - SymlinkTarget: "foo.txt", - Name: "foo.txt", - }, - Changes: &discovery.Changes{ - OldPath: "", - Lines: []git.LineNumber{ - {Before: 0, After: 2, Modified: true}, - {Before: 0, After: 4, Modified: true}, - }, - }, - Rule: mockFile.Groups[0].Rules[1], - Problem: checks.Problem{ - Lines: diags.LineRange{ - First: 2, - Last: 2, - }, - Reporter: "mock", - Summary: "bad name", - Severity: checks.Fatal, - }, - }, - { - Path: discovery.Path{ - SymlinkTarget: "foo.txt", - Name: "foo.txt", - }, - Changes: &discovery.Changes{ - OldPath: "", - Lines: []git.LineNumber{ - {Before: 0, After: 2, Modified: true}, - {Before: 0, After: 4, Modified: true}, - }, - }, - Rule: mockFile.Groups[0].Rules[0], - Problem: checks.Problem{ - Lines: diags.LineRange{ - First: 2, - Last: 2, - }, - Reporter: "mock", - Summary: "mock text", - Details: "mock details", - Severity: checks.Bug, - }, - }, + } + activitiesJSON, _ := json.Marshal(activities) + + srv := httpmock.New(func(s *httpmock.Server) { + s.ExpectGet("/plugins/servlet/applinks/whoami"). + Return("testuser"). + Once() + s.ExpectGet("/rest/api/latest/projects/proj/repos/repo/pull-requests/1/activities?start=0"). + ReturnHeader("Content-Type", "application/json"). + Return(string(activitiesJSON)). + Once() + })(t) + + bb := bitBucketAPI{ + uri: srv.URL(), + authToken: "test-token", + timeout: time.Second * 5, + project: "proj", + repo: "repo", + } + + pr := &bitBucketPR{ID: 1} + comments, err := bb.getPullRequestComments(pr) + require.NoError(t, err) + require.Len(t, comments, 1) + require.Equal(t, 1, comments[0].id) + require.Equal(t, "valid comment", comments[0].text) + }) + + t.Run("handles pagination", func(t *testing.T) { + page1, _ := json.Marshal(BitBucketPullRequestActivities{ + IsLastPage: false, + NextPageStart: 1, + Values: []BitBucketPullRequestActivity{ { - Path: discovery.Path{ - SymlinkTarget: "foo.txt", - Name: "symlink.txt", - }, - Changes: &discovery.Changes{ - OldPath: "", - Lines: []git.LineNumber{ - {Before: 0, After: 2, Modified: true}, - {Before: 0, After: 4, Modified: true}, - }, - }, - Rule: mockFile.Groups[0].Rules[1], - Problem: checks.Problem{ - Lines: diags.LineRange{ - First: 4, - Last: 4, - }, - Reporter: "mock", - Summary: "mock text 2", - Severity: checks.Warning, - }, + Action: "COMMENTED", + CommentAction: "ADDED", + Comment: BitBucketPullRequestComment{ + ID: 1, + State: "OPEN", + Author: BitBucketCommentAuthor{Name: "testuser"}, + Text: "comment 1", + }, + CommentAnchor: BitBucketCommentAnchor{Path: "foo.yaml"}, }, }, - errorHandler: func(err error) error { - if err.Error() != "fatal error(s) reported" { - return fmt.Errorf("Unpexpected error: %w", err) - } - return nil - }, - }, - { - description: "sends a correct report using comments, fails to delete stale comments", - gitCmd: fakeGit, - mock: httpmock.New(func(s *httpmock.Server) { - s.ExpectDelete("/rest/insights/1.0/projects/proj/repos/repo/commits/fake-commit-id/reports/pint"). - Once() - s.ExpectPut("/rest/insights/1.0/projects/proj/repos/repo/commits/fake-commit-id/reports/pint"). - Once() - s.ExpectGet("/rest/api/1.0/projects/proj/repos/repo/commits/fake-commit-id/pull-requests?start=0"). - ReturnJSON(reporter.BitBucketPullRequests{ - IsLastPage: true, - Values: []reporter.BitBucketPullRequest{ - { - ID: 102, - Open: true, - FromRef: reporter.BitBucketRef{ - ID: "refs/heads/fake-branch", - Commit: "fake-commit-id", - }, - ToRef: reporter.BitBucketRef{ - ID: "refs/heads/main", - Commit: "main-commit-id", - }, - }, - }, - }). - Once() - s.ExpectGet("/rest/api/1.0/projects/proj/repos/repo/pull-requests/102/changes?start=0"). - ReturnJSON(reporter.BitBucketPullRequestChanges{ - IsLastPage: true, - Values: []reporter.BitBucketPullRequestChange{ - { - Path: reporter.BitBucketPath{ - ToString: "index.txt", - }, - }, - }, - }). - Once() - s.ExpectGet("/rest/api/latest/projects/proj/repos/repo/commits/fake-commit-id/diff/index.txt?contextLines=10000&since=main-commit-id&whitespace=show&withComments=false"). - ReturnJSON(reporter.BitBucketFileDiffs{ - Diffs: []reporter.BitBucketFileDiff{ - { - Hunks: []reporter.BitBucketDiffHunk{ - { - Segments: []reporter.BitBucketDiffSegment{ - { - Type: "ADDED", - Lines: []reporter.BitBucketDiffLine{ - {Source: 1, Destination: 1}, - {Source: 5, Destination: 5}, - }, - }, - { - Type: "CONTEXT", - Lines: []reporter.BitBucketDiffLine{ - {Source: 10, Destination: 6}, - }, - }, - }, - }, - }, - }, - }, - }). - Once() - s.ExpectGet("/plugins/servlet/applinks/whoami"). - Return("pint_user"). - Once() - s.ExpectGet("/rest/api/latest/projects/proj/repos/repo/pull-requests/102/activities?start=0"). - ReturnJSON(reporter.BitBucketPullRequestActivities{ - IsLastPage: true, - Values: []reporter.BitBucketPullRequestActivity{ - { - Action: "COMMENTED", - CommentAction: "ADDED", - CommentAnchor: reporter.BitBucketCommentAnchor{ - Orphaned: false, - DiffType: "EFFECTIVE", - Path: "index.txt", - Line: 3, - }, - Comment: reporter.BitBucketPullRequestComment{ - ID: 1001, - Version: 0, - State: "OPEN", - Author: reporter.BitBucketCommentAuthor{ - Name: "pint_user", - }, - }, - }, - { - Action: "COMMENTED", - CommentAction: "ADDED", - CommentAnchor: reporter.BitBucketCommentAnchor{ - Orphaned: false, - DiffType: "COMMIT", - Path: "index.txt", - Line: 10, - }, - Comment: reporter.BitBucketPullRequestComment{ - ID: 1002, - Version: 1, - State: "OPEN", - Author: reporter.BitBucketCommentAuthor{ - Name: "pint_user", - }, - }, - }, - }, - }). - Once() - // pruneComments will try to delete both comments, which fails (500), but errors are only logged. - s.ExpectDelete("/rest/api/1.0/projects/proj/repos/repo/pull-requests/102/comments/1001?version=0"). - ReturnCode(http.StatusInternalServerError). - Once() - s.ExpectDelete("/rest/api/1.0/projects/proj/repos/repo/pull-requests/102/comments/1002?version=1"). - ReturnCode(http.StatusInternalServerError). - Once() - }), - errorHandler: func(err error) error { - if err != nil { - return fmt.Errorf("Unpexpected error: %w", err) - } - return nil - }, - }, - { - description: "sends a correct report using comments, fails to get username", - gitCmd: fakeGit, - mock: httpmock.New(func(s *httpmock.Server) { - s.ExpectDelete("/rest/insights/1.0/projects/proj/repos/repo/commits/fake-commit-id/reports/pint"). - Once() - s.ExpectPut("/rest/insights/1.0/projects/proj/repos/repo/commits/fake-commit-id/reports/pint"). - Once() - s.ExpectGet("/rest/api/1.0/projects/proj/repos/repo/commits/fake-commit-id/pull-requests?start=0"). - ReturnJSON(reporter.BitBucketPullRequests{ - IsLastPage: true, - Values: []reporter.BitBucketPullRequest{ - { - ID: 102, - Open: true, - FromRef: reporter.BitBucketRef{ - ID: "refs/heads/fake-branch", - Commit: "fake-commit-id", - }, - ToRef: reporter.BitBucketRef{ - ID: "refs/heads/main", - Commit: "main-commit-id", - }, - }, - }, - }). - Once() - s.ExpectGet("/rest/api/1.0/projects/proj/repos/repo/pull-requests/102/changes?start=0"). - ReturnJSON(reporter.BitBucketPullRequestChanges{ - IsLastPage: true, - Values: []reporter.BitBucketPullRequestChange{ - { - Path: reporter.BitBucketPath{ - ToString: "index.txt", - }, - }, - }, - }). - Once() - s.ExpectGet("/rest/api/latest/projects/proj/repos/repo/commits/fake-commit-id/diff/index.txt?contextLines=10000&since=main-commit-id&whitespace=show&withComments=false"). - ReturnJSON(reporter.BitBucketFileDiffs{ - Diffs: []reporter.BitBucketFileDiff{ - { - Hunks: []reporter.BitBucketDiffHunk{ - { - Segments: []reporter.BitBucketDiffSegment{ - { - Type: "ADDED", - Lines: []reporter.BitBucketDiffLine{ - {Source: 1, Destination: 1}, - {Source: 5, Destination: 5}, - }, - }, - { - Type: "CONTEXT", - Lines: []reporter.BitBucketDiffLine{ - {Source: 10, Destination: 6}, - }, - }, - }, - }, - }, - }, - }, - }). - Once() - s.ExpectGet("/plugins/servlet/applinks/whoami"). - ReturnCode(http.StatusInternalServerError). - Once() - }), - errorHandler: func(err error) error { - if err.Error() != "failed to get pull request comments from BitBucket: GET request failed" { - return fmt.Errorf("Unpexpected error: %w", err) - } - return nil - }, - }, - { - description: "sends a correct report using comments, fails to create new comments", - gitCmd: fakeGit, - reports: []reporter.Report{ + }) + page2, _ := json.Marshal(BitBucketPullRequestActivities{ + IsLastPage: true, + Values: []BitBucketPullRequestActivity{ { - Path: discovery.Path{ - SymlinkTarget: "index.txt", - Name: "foo.txt", - }, - Changes: &discovery.Changes{ - OldPath: "", - Lines: []git.LineNumber{ - {Before: 0, After: 2, Modified: true}, - {Before: 0, After: 4, Modified: true}, - }, - }, - Rule: mockFile.Groups[0].Rules[1], - Problem: checks.Problem{ - Lines: diags.LineRange{ - First: 1, - Last: 1, - }, - Reporter: "mock", - Summary: "this should be ignored, line is not part of the diff", - Severity: checks.Bug, - }, + Action: "COMMENTED", + CommentAction: "ADDED", + Comment: BitBucketPullRequestComment{ + ID: 2, + State: "OPEN", + Author: BitBucketCommentAuthor{Name: "testuser"}, + Text: "comment 2", + }, + CommentAnchor: BitBucketCommentAnchor{Path: "bar.yaml"}, }, }, - mock: httpmock.New(func(s *httpmock.Server) { - s.ExpectDelete("/rest/insights/1.0/projects/proj/repos/repo/commits/fake-commit-id/reports/pint"). - Once() - s.ExpectPut("/rest/insights/1.0/projects/proj/repos/repo/commits/fake-commit-id/reports/pint"). - Once() - s.ExpectGet("/rest/api/1.0/projects/proj/repos/repo/commits/fake-commit-id/pull-requests?start=0"). - ReturnJSON(reporter.BitBucketPullRequests{ - IsLastPage: true, - Values: []reporter.BitBucketPullRequest{ - { - ID: 102, - Open: true, - FromRef: reporter.BitBucketRef{ - ID: "refs/heads/fake-branch", - Commit: "fake-commit-id", - }, - ToRef: reporter.BitBucketRef{ - ID: "refs/heads/main", - Commit: "main-commit-id", - }, - }, - }, - }). - Once() - s.ExpectGet("/rest/api/1.0/projects/proj/repos/repo/pull-requests/102/changes?start=0"). - ReturnJSON(reporter.BitBucketPullRequestChanges{ - IsLastPage: true, - Values: []reporter.BitBucketPullRequestChange{ - { - Path: reporter.BitBucketPath{ - ToString: "index.txt", - }, - }, - }, - }). - Once() - s.ExpectGet("/rest/api/latest/projects/proj/repos/repo/commits/fake-commit-id/diff/index.txt?contextLines=10000&since=main-commit-id&whitespace=show&withComments=false"). - ReturnJSON(reporter.BitBucketFileDiffs{ - Diffs: []reporter.BitBucketFileDiff{ - { - Hunks: []reporter.BitBucketDiffHunk{ - { - Segments: []reporter.BitBucketDiffSegment{ - { - Type: "ADDED", - Lines: []reporter.BitBucketDiffLine{ - {Source: 1, Destination: 1}, - {Source: 5, Destination: 5}, - }, - }, - { - Type: "CONTEXT", - Lines: []reporter.BitBucketDiffLine{ - {Source: 10, Destination: 6}, - }, - }, - }, - }, - }, - }, - }, - }). - Once() - s.ExpectGet("/plugins/servlet/applinks/whoami"). - Return("pint_user"). - Once() - s.ExpectGet("/rest/api/latest/projects/proj/repos/repo/pull-requests/102/activities?start=0"). - ReturnJSON(reporter.BitBucketPullRequestActivities{ - IsLastPage: true, - Values: []reporter.BitBucketPullRequestActivity{ - { - Action: "COMMENTED", - CommentAction: "ADDED", - CommentAnchor: reporter.BitBucketCommentAnchor{ - Orphaned: false, - DiffType: "EFFECTIVE", - Path: "index.txt", - Line: 3, - }, - Comment: reporter.BitBucketPullRequestComment{ - ID: 1001, - Version: 0, - State: "OPEN", - Author: reporter.BitBucketCommentAuthor{ - Name: "pint_user", - }, - }, - }, - { - Action: "COMMENTED", - CommentAction: "ADDED", - CommentAnchor: reporter.BitBucketCommentAnchor{ - Orphaned: false, - DiffType: "COMMIT", - Path: "index.txt", - Line: 10, - }, - Comment: reporter.BitBucketPullRequestComment{ - ID: 1002, - Version: 1, - State: "OPEN", - Author: reporter.BitBucketCommentAuthor{ - Name: "pint_user", - }, - }, - }, - }, - }). - Once() - // pruneComments deletes both stale comments. - s.ExpectDelete("/rest/api/1.0/projects/proj/repos/repo/pull-requests/102/comments/1001?version=0"). - Once() - s.ExpectDelete("/rest/api/1.0/projects/proj/repos/repo/pull-requests/102/comments/1002?version=1"). - Once() - // addComments tries to POST new comment, which fails. - s.ExpectPost("/rest/api/1.0/projects/proj/repos/repo/pull-requests/102/comments"). - ReturnCode(http.StatusInternalServerError). - Once() - }), - errorHandler: func(err error) error { - if err != nil && err.Error() == "failed to create BitBucket pull request comments: POST request failed" { - return nil - } - return fmt.Errorf("Expected failed to create BitBucket pull request comments: POST request failed, got %w", err) - }, - }, - { - description: "sends a correct report with deduped comments", - gitCmd: fakeGit, - mock: httpmock.New(func(s *httpmock.Server) { - s.ExpectDelete("/rest/insights/1.0/projects/proj/repos/repo/commits/fake-commit-id/reports/pint"). - Once() - s.ExpectPut("/rest/insights/1.0/projects/proj/repos/repo/commits/fake-commit-id/reports/pint"). - Once() - s.ExpectGet("/rest/api/1.0/projects/proj/repos/repo/commits/fake-commit-id/pull-requests?start=0"). - ReturnJSON(reporter.BitBucketPullRequests{ - IsLastPage: true, - Values: []reporter.BitBucketPullRequest{ - { - ID: 102, - Open: true, - FromRef: reporter.BitBucketRef{ - ID: "refs/heads/fake-branch", - Commit: "fake-commit-id", - }, - ToRef: reporter.BitBucketRef{ - ID: "refs/heads/main", - Commit: "main-commit-id", - }, - }, - }, - }). - Once() - s.ExpectGet("/rest/api/1.0/projects/proj/repos/repo/pull-requests/102/changes?start=0"). - ReturnJSON(reporter.BitBucketPullRequestChanges{ - IsLastPage: true, - Values: []reporter.BitBucketPullRequestChange{ - {Path: reporter.BitBucketPath{ToString: "index.txt"}}, - {Path: reporter.BitBucketPath{ToString: "foo.txt"}}, - }, - }). - Once() - s.ExpectGet("/rest/api/latest/projects/proj/repos/repo/commits/fake-commit-id/diff/index.txt?contextLines=10000&since=main-commit-id&whitespace=show&withComments=false"). - ReturnJSON(reporter.BitBucketFileDiffs{ - Diffs: []reporter.BitBucketFileDiff{ - { - Hunks: []reporter.BitBucketDiffHunk{ - { - Segments: []reporter.BitBucketDiffSegment{ - { - Type: "ADDED", - Lines: []reporter.BitBucketDiffLine{ - {Source: 1, Destination: 1}, - {Source: 5, Destination: 5}, - }, - }, - { - Type: "CONTEXT", - Lines: []reporter.BitBucketDiffLine{ - {Source: 10, Destination: 6}, - }, - }, - }, - }, - }, - }, - }, - }). - Once() - s.ExpectGet("/rest/api/latest/projects/proj/repos/repo/commits/fake-commit-id/diff/foo.txt?contextLines=10000&since=main-commit-id&whitespace=show&withComments=false"). - ReturnJSON(reporter.BitBucketFileDiffs{ - Diffs: []reporter.BitBucketFileDiff{ - { - Hunks: []reporter.BitBucketDiffHunk{ - { - Segments: []reporter.BitBucketDiffSegment{ - { - Type: "ADDED", - Lines: []reporter.BitBucketDiffLine{ - {Source: 2, Destination: 2}, - }, - }, - }, - }, - }, - }, - { - Hunks: []reporter.BitBucketDiffHunk{ - { - Segments: []reporter.BitBucketDiffSegment{ - { - Type: "MODIFIED", - Lines: []reporter.BitBucketDiffLine{ - {Source: 3, Destination: 4}, - }, - }, - }, - }, - }, - }, - }, - }). - Once() - s.ExpectGet("/plugins/servlet/applinks/whoami"). - Return("pint_user"). - Once() - s.ExpectGet("/rest/api/latest/projects/proj/repos/repo/pull-requests/102/activities?start=0"). - ReturnJSON(reporter.BitBucketPullRequestActivities{ - IsLastPage: true, - Values: []reporter.BitBucketPullRequestActivity{ - {Action: "APPROVED"}, - { - Action: "COMMENTED", - CommentAction: "ADDED", - CommentAnchor: reporter.BitBucketCommentAnchor{ - Orphaned: true, - DiffType: "EFFECTIVE", - Path: "foo.txt", - Line: 3, - }, - Comment: reporter.BitBucketPullRequestComment{ - ID: 1001, - Version: 0, - State: "OPEN", - Author: reporter.BitBucketCommentAuthor{Name: "pint_user"}, - }, - }, - { - Action: "COMMENTED", - CommentAction: "ADDED", - CommentAnchor: reporter.BitBucketCommentAnchor{ - Orphaned: true, - DiffType: "COMMIT", - Path: "foo.txt", - Line: 10, - }, - Comment: reporter.BitBucketPullRequestComment{ - ID: 1002, - Version: 1, - State: "OPEN", - Author: reporter.BitBucketCommentAuthor{Name: "pint_user"}, - }, - }, - { - Action: "COMMENTED", - CommentAction: "ADDED", - CommentAnchor: reporter.BitBucketCommentAnchor{ - Orphaned: false, - DiffType: "EFFECTIVE", - Path: "foo.txt", - Line: 3, - }, - Comment: reporter.BitBucketPullRequestComment{ - ID: 2001, - Version: 0, - State: "OPEN", - Author: reporter.BitBucketCommentAuthor{Name: "pint_user"}, - }, - }, - { - Action: "COMMENTED", - CommentAction: "ADDED", - CommentAnchor: reporter.BitBucketCommentAnchor{ - Orphaned: false, - DiffType: "COMMIT", - Path: "foo.txt", - Line: 4, - }, - Comment: reporter.BitBucketPullRequestComment{ - ID: 2002, - Version: 1, - State: "OPEN", - Author: reporter.BitBucketCommentAuthor{Name: "pint_user"}, - }, - }, - }, - }). - Once() - // pruneComments deletes all stale comments. - s.ExpectDelete("/rest/api/1.0/projects/proj/repos/repo/pull-requests/102/comments/1001?version=0"). - Once() - s.ExpectDelete("/rest/api/1.0/projects/proj/repos/repo/pull-requests/102/comments/1002?version=1"). - Once() - s.ExpectDelete("/rest/api/1.0/projects/proj/repos/repo/pull-requests/102/comments/2001?version=0"). - Once() - s.ExpectDelete("/rest/api/1.0/projects/proj/repos/repo/pull-requests/102/comments/2002?version=1"). - Once() - // addComments posts 2 deduped comments. - s.ExpectPost("/rest/api/1.0/projects/proj/repos/repo/pull-requests/102/comments"). - Once() - s.ExpectPost("/rest/api/1.0/projects/proj/repos/repo/pull-requests/102/comments"). - Once() - }), - reports: []reporter.Report{ - { - Path: discovery.Path{ - SymlinkTarget: "foo.txt", - Name: "foo.txt", - }, - Changes: &discovery.Changes{ - OldPath: "", - Lines: []git.LineNumber{ - {Before: 0, After: 2, Modified: true}, - {Before: 0, After: 4, Modified: true}, - }, - }, - Rule: mockFile.Groups[0].Rules[1], - Problem: checks.Problem{ - Lines: diags.LineRange{ - First: 1, - Last: 1, - }, - Reporter: "mock", - Summary: "this should be ignored, line is not part of the diff", - Severity: checks.Bug, - }, - }, - { - Path: discovery.Path{ - SymlinkTarget: "foo.txt", - Name: "foo.txt", - }, - Changes: &discovery.Changes{ - OldPath: "", - Lines: []git.LineNumber{ - {Before: 0, After: 2, Modified: true}, - {Before: 0, After: 4, Modified: true}, - }, - }, - Rule: mockFile.Groups[0].Rules[1], - Problem: checks.Problem{ - Lines: diags.LineRange{ - First: 1, - Last: 1, - }, - Reporter: "mock", - Summary: "this should be ignored, line is not part of the diff", - Severity: checks.Bug, - }, - }, - { - Path: discovery.Path{ - SymlinkTarget: "foo.txt", - Name: "foo.txt", - }, - Changes: &discovery.Changes{ - OldPath: "", - Lines: []git.LineNumber{ - {Before: 0, After: 2, Modified: true}, - {Before: 0, After: 4, Modified: true}, - }, - }, - Rule: mockFile.Groups[0].Rules[1], - Problem: checks.Problem{ - Lines: diags.LineRange{ - First: 2, - Last: 2, - }, - Reporter: "mock", - Summary: "bad name", - Details: "bad name details", - Severity: checks.Warning, - }, - }, - { - Path: discovery.Path{ - SymlinkTarget: "foo.txt", - Name: "foo.txt", - }, - Changes: &discovery.Changes{ - OldPath: "", - Lines: []git.LineNumber{ - {Before: 0, After: 2, Modified: true}, - {Before: 0, After: 4, Modified: true}, - }, - }, - Rule: mockFile.Groups[0].Rules[0], - Problem: checks.Problem{ - Lines: diags.LineRange{ - First: 2, - Last: 2, - }, - Reporter: "mock", - Summary: "mock text 1", - Details: "mock details", - Severity: checks.Warning, - }, - }, + }) + + srv := httpmock.New(func(s *httpmock.Server) { + s.ExpectGet("/plugins/servlet/applinks/whoami"). + Return("testuser"). + Once() + s.ExpectGet("/rest/api/latest/projects/proj/repos/repo/pull-requests/1/activities?start=0"). + ReturnHeader("Content-Type", "application/json"). + Return(string(page1)). + Once() + s.ExpectGet("/rest/api/latest/projects/proj/repos/repo/pull-requests/1/activities?start=1"). + ReturnHeader("Content-Type", "application/json"). + Return(string(page2)). + Once() + })(t) + + bb := bitBucketAPI{ + uri: srv.URL(), + authToken: "test-token", + timeout: time.Second * 5, + project: "proj", + repo: "repo", + } + + pr := &bitBucketPR{ID: 1} + comments, err := bb.getPullRequestComments(pr) + require.NoError(t, err) + require.Len(t, comments, 2) + }) + + t.Run("returns error on invalid JSON", func(t *testing.T) { + srv := httpmock.New(func(s *httpmock.Server) { + s.ExpectGet("/plugins/servlet/applinks/whoami"). + Return("testuser"). + Once() + s.ExpectGet("/rest/api/latest/projects/proj/repos/repo/pull-requests/1/activities?start=0"). + Return("invalid json"). + Once() + })(t) + + bb := bitBucketAPI{ + uri: srv.URL(), + authToken: "test-token", + timeout: time.Second * 5, + project: "proj", + repo: "repo", + } + + pr := &bitBucketPR{ID: 1} + _, err := bb.getPullRequestComments(pr) + require.Error(t, err) + }) +} + +func TestFindPullRequestForBranchErrors(t *testing.T) { + slog.SetDefault(slogt.New(t)) + + t.Run("returns error on invalid JSON", func(t *testing.T) { + srv := httpmock.New(func(s *httpmock.Server) { + s.ExpectGet("/rest/api/1.0/projects/proj/repos/repo/commits/commit123/pull-requests?start=0"). + Return("invalid json"). + Once() + })(t) + + bb := bitBucketAPI{ + uri: srv.URL(), + authToken: "test-token", + timeout: time.Second * 5, + project: "proj", + repo: "repo", + } + + _, err := bb.findPullRequestForBranch("feature", "commit123") + require.Error(t, err) + }) + + t.Run("paginates through results", func(t *testing.T) { + page1, _ := json.Marshal(BitBucketPullRequests{ + IsLastPage: false, + NextPageStart: 1, + Values: []BitBucketPullRequest{ { - Path: discovery.Path{ - SymlinkTarget: "foo.txt", - Name: "symlink.txt", - }, - Changes: &discovery.Changes{ - OldPath: "", - Lines: []git.LineNumber{ - {Before: 0, After: 2, Modified: true}, - {Before: 0, After: 4, Modified: true}, - }, - }, - Rule: mockFile.Groups[0].Rules[1], - Problem: checks.Problem{ - Lines: diags.LineRange{ - First: 2, - Last: 2, - }, - Reporter: "mock", - Summary: "mock text 2", - Details: "mock details", - Severity: checks.Warning, - }, + ID: 1, + Open: true, + FromRef: BitBucketRef{ID: "refs/heads/other"}, + ToRef: BitBucketRef{ID: "refs/heads/main"}, }, }, - errorHandler: func(err error) error { - if err != nil { - return fmt.Errorf("Unpexpected error: %w", err) - } - return nil - }, - }, - { - description: "annotation on unmodified lines", - gitCmd: fakeGit, - mock: httpmock.New(func(s *httpmock.Server) { - s.ExpectDelete("/rest/insights/1.0/projects/proj/repos/repo/commits/fake-commit-id/reports/pint"). - Once() - s.ExpectPut("/rest/insights/1.0/projects/proj/repos/repo/commits/fake-commit-id/reports/pint"). - Once() - s.ExpectGet("/rest/api/1.0/projects/proj/repos/repo/commits/fake-commit-id/pull-requests?start=0"). - ReturnJSON(reporter.BitBucketPullRequests{IsLastPage: true}). - Once() - s.ExpectDelete("/rest/insights/1.0/projects/proj/repos/repo/commits/fake-commit-id/reports/pint/annotations"). - Once() - }), - reports: []reporter.Report{ + }) + page2, _ := json.Marshal(BitBucketPullRequests{ + IsLastPage: true, + Values: []BitBucketPullRequest{ { - Path: discovery.Path{ - SymlinkTarget: "foo.txt", - Name: "foo.txt", - }, - Changes: &discovery.Changes{ - OldPath: "", - Lines: git.LineNumbers{}, - }, - Rule: mockFile.Groups[0].Rules[1], - Problem: checks.Problem{ - Lines: diags.LineRange{ - First: 1, - Last: 1, - }, - Reporter: "mock", - Summary: "line is not part of the diff", - Severity: checks.Bug, - }, + ID: 2, + Open: true, + FromRef: BitBucketRef{ID: "refs/heads/feature", Commit: "abc123"}, + ToRef: BitBucketRef{ID: "refs/heads/main", Commit: "def456"}, }, }, - errorHandler: func(err error) error { - if err != nil { - return fmt.Errorf("Unpexpected error: %w", err) - } - return nil - }, - }, - { - // Covers bitbucket_api.go:633-652 — diagnostics rendering in makeComments. - description: "comment includes diagnostics when file is readable", - gitCmd: fakeGit, - reports: []reporter.Report{ - { - Path: discovery.Path{ - SymlinkTarget: "index.txt", - Name: diagFile, - }, - Changes: &discovery.Changes{ - OldPath: "", - Lines: git.LineNumbers{{Before: 0, After: 1, Modified: true}}, - }, - Rule: mockFile.Groups[0].Rules[0], - Problem: checks.Problem{ - Lines: diags.LineRange{ - First: 1, - Last: 1, - }, - Reporter: "mock", - Summary: "problem with diagnostics", - Severity: checks.Bug, - Anchor: checks.AnchorAfter, - Diagnostics: []diags.Diagnostic{ - { - Message: "this is wrong", - Pos: diags.PositionRanges{ - {Line: 1, FirstColumn: 3, LastColumn: 8}, - }, - FirstColumn: 3, - LastColumn: 8, - }, - }, - }, - }, + }) + + srv := httpmock.New(func(s *httpmock.Server) { + s.ExpectGet("/rest/api/1.0/projects/proj/repos/repo/commits/commit123/pull-requests?start=0"). + ReturnHeader("Content-Type", "application/json"). + Return(string(page1)). + Once() + s.ExpectGet("/rest/api/1.0/projects/proj/repos/repo/commits/commit123/pull-requests?start=1"). + ReturnHeader("Content-Type", "application/json"). + Return(string(page2)). + Once() + })(t) + + bb := bitBucketAPI{ + uri: srv.URL(), + authToken: "test-token", + timeout: time.Second * 5, + project: "proj", + repo: "repo", + } + + pr, err := bb.findPullRequestForBranch("feature", "commit123") + require.NoError(t, err) + require.NotNil(t, pr) + require.Equal(t, 2, pr.ID) + }) +} + +func TestBitBucketAPICreateComment(t *testing.T) { + slog.SetDefault(slogt.New(t)) + + // Verifies that createComment sends a POST with serialized comment body. + t.Run("sends POST request with comment payload", func(t *testing.T) { + srv := httpmock.New(func(s *httpmock.Server) { + s.ExpectPost("/rest/api/1.0/projects/proj/repos/repo/pull-requests/1/comments"). + Return("{}"). + Once() + })(t) + + bb := bitBucketAPI{ + uri: srv.URL(), + authToken: "test-token", + timeout: time.Second * 5, + project: "proj", + repo: "repo", + } + + pr := &bitBucketPR{ID: 1} + comment := BitBucketPendingComment{ + Text: "test comment", + Severity: "NORMAL", + Anchor: bitBucketPendingCommentAnchor{ + Path: "foo.yaml", + Line: 10, + DiffType: "EFFECTIVE", + LineType: "ADDED", + FileType: "TO", }, - mock: httpmock.New(func(s *httpmock.Server) { - s.ExpectDelete("/rest/insights/1.0/projects/proj/repos/repo/commits/fake-commit-id/reports/pint"). - Once() - s.ExpectPut("/rest/insights/1.0/projects/proj/repos/repo/commits/fake-commit-id/reports/pint"). - Once() - s.ExpectGet("/rest/api/1.0/projects/proj/repos/repo/commits/fake-commit-id/pull-requests?start=0"). - ReturnJSON(reporter.BitBucketPullRequests{ - IsLastPage: true, - Values: []reporter.BitBucketPullRequest{ - { - ID: 102, - Open: true, - FromRef: reporter.BitBucketRef{ - ID: "refs/heads/fake-branch", - Commit: "fake-commit-id", - }, - ToRef: reporter.BitBucketRef{ - ID: "refs/heads/main", - Commit: "main-commit-id", - }, - }, - }, - }). - Once() - s.ExpectGet("/rest/api/1.0/projects/proj/repos/repo/pull-requests/102/changes?start=0"). - ReturnJSON(reporter.BitBucketPullRequestChanges{ - IsLastPage: true, - Values: []reporter.BitBucketPullRequestChange{ - { - Path: reporter.BitBucketPath{ - ToString: "index.txt", - }, - }, - }, - }). - Once() - s.ExpectGet("/rest/api/latest/projects/proj/repos/repo/commits/fake-commit-id/diff/index.txt?contextLines=10000&since=main-commit-id&whitespace=show&withComments=false"). - ReturnJSON(reporter.BitBucketFileDiffs{ - Diffs: []reporter.BitBucketFileDiff{ - { - Hunks: []reporter.BitBucketDiffHunk{ - { - Segments: []reporter.BitBucketDiffSegment{ - { - Type: "ADDED", - Lines: []reporter.BitBucketDiffLine{ - {Source: 1, Destination: 1}, - }, - }, - }, - }, - }, - }, - }, - }). - Once() - s.ExpectGet("/plugins/servlet/applinks/whoami"). - Return("pint_user"). - Once() - s.ExpectGet("/rest/api/latest/projects/proj/repos/repo/pull-requests/102/activities?start=0"). - ReturnJSON(reporter.BitBucketPullRequestActivities{IsLastPage: true}). - Once() - // addComments posts 1 new comment with diagnostics content. - s.ExpectPost("/rest/api/1.0/projects/proj/repos/repo/pull-requests/102/comments"). - Once() - }), - errorHandler: func(err error) error { - if err != nil { - return fmt.Errorf("Unpexpected error: %w", err) - } - return nil + } + + err := bb.createComment(pr, comment) + require.NoError(t, err) + require.Len(t, srv.Requests, 1) + require.Equal(t, http.MethodPost, srv.Requests[0].Method()) + }) + + // Verifies that createComment returns an error on server failure. + t.Run("returns error on server failure", func(t *testing.T) { + srv := httpmock.New(func(s *httpmock.Server) { + s.ExpectPost("/rest/api/1.0/projects/proj/repos/repo/pull-requests/1/comments"). + ReturnCode(http.StatusInternalServerError). + Once() + })(t) + + bb := bitBucketAPI{ + uri: srv.URL(), + authToken: "test-token", + timeout: time.Second * 5, + project: "proj", + repo: "repo", + } + + pr := &bitBucketPR{ID: 1} + comment := BitBucketPendingComment{ + Text: "test comment", + Severity: "NORMAL", + } + + err := bb.createComment(pr, comment) + require.Error(t, err) + }) +} + +func TestBitBucketAPIDeleteComment(t *testing.T) { + slog.SetDefault(slogt.New(t)) + + // Verifies that deleteComment sends a DELETE request with correct comment ID and version. + t.Run("sends DELETE request with comment ID and version", func(t *testing.T) { + srv := httpmock.New(func(s *httpmock.Server) { + s.ExpectDelete("/rest/api/1.0/projects/proj/repos/repo/pull-requests/1/comments/42?version=3"). + Once() + })(t) + + bb := bitBucketAPI{ + uri: srv.URL(), + authToken: "test-token", + timeout: time.Second * 5, + project: "proj", + repo: "repo", + } + + pr := &bitBucketPR{ID: 1} + err := bb.deleteComment(pr, 42, 3) + require.NoError(t, err) + require.Len(t, srv.Requests, 1) + require.Equal(t, http.MethodDelete, srv.Requests[0].Method()) + }) + + // Verifies that deleteComment returns an error on server failure. + t.Run("returns error on server failure", func(t *testing.T) { + srv := httpmock.New(func(s *httpmock.Server) { + s.ExpectDelete("/rest/api/1.0/projects/proj/repos/repo/pull-requests/1/comments/42?version=3"). + ReturnCode(http.StatusInternalServerError). + Once() + })(t) + + bb := bitBucketAPI{ + uri: srv.URL(), + authToken: "test-token", + timeout: time.Second * 5, + project: "proj", + repo: "repo", + } + + pr := &bitBucketPR{ID: 1} + err := bb.deleteComment(pr, 42, 3) + require.Error(t, err) + }) +} + +func TestBitBucketAPIRequestDoError(t *testing.T) { + slog.SetDefault(slogt.New(t)) + + // Verifies that request returns error when HTTP client cannot connect. + bb := bitBucketAPI{ + uri: "http://127.0.0.1:0", + authToken: "test-token", + timeout: time.Millisecond * 100, + } + + _, err := bb.request(http.MethodGet, "/test", nil) + require.Error(t, err) +} + +func TestBitBucketAPIRequestReadBodyError(t *testing.T) { + slog.SetDefault(slogt.New(t)) + + // Verifies that request returns error when reading the response body fails. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Length", "100") + w.WriteHeader(http.StatusOK) + // Write fewer bytes than Content-Length then close, causing io.ReadAll to fail. + _, _ = io.WriteString(w, "partial") + })) + t.Cleanup(srv.Close) + + bb := bitBucketAPI{ + uri: srv.URL, + authToken: "test-token", + timeout: time.Second, + } + + _, err := bb.request(http.MethodGet, "/test", nil) + require.Error(t, err) +} + +func TestBitBucketAPIWhoamiError(t *testing.T) { + slog.SetDefault(slogt.New(t)) + + // Verifies that whoami returns error when the request fails. + srv := httpmock.New(func(s *httpmock.Server) { + s.ExpectGet("/plugins/servlet/applinks/whoami"). + ReturnCode(http.StatusInternalServerError). + Once() + })(t) + + bb := bitBucketAPI{ + uri: srv.URL(), + authToken: "test-token", + timeout: time.Second, + } + + _, err := bb.whoami() + require.EqualError(t, err, "GET request failed") +} + +func TestFindPullRequestForBranchRequestError(t *testing.T) { + slog.SetDefault(slogt.New(t)) + + // Verifies that findPullRequestForBranch returns error when the HTTP request fails. + srv := httpmock.New(func(s *httpmock.Server) { + s.ExpectGet("/rest/api/1.0/projects/proj/repos/repo/commits/abc/pull-requests?start=0"). + ReturnCode(http.StatusInternalServerError). + Once() + })(t) + + bb := bitBucketAPI{ + uri: srv.URL(), + authToken: "test-token", + timeout: time.Second, + project: "proj", + repo: "repo", + } + + _, err := bb.findPullRequestForBranch("feature", "abc") + require.EqualError(t, err, "GET request failed") +} + +func TestFindPullRequestForBranchSkipsClosedPR(t *testing.T) { + slog.SetDefault(slogt.New(t)) + + // Verifies that closed pull requests are skipped and nil is returned. + prsJSON, _ := json.Marshal(BitBucketPullRequests{ + IsLastPage: true, + Values: []BitBucketPullRequest{ + { + ID: 1, + Open: false, + FromRef: BitBucketRef{ID: "refs/heads/feature"}, + ToRef: BitBucketRef{ID: "refs/heads/main"}, }, }, + }) + + srv := httpmock.New(func(s *httpmock.Server) { + s.ExpectGet("/rest/api/1.0/projects/proj/repos/repo/commits/abc/pull-requests?start=0"). + ReturnHeader("Content-Type", "application/json"). + Return(string(prsJSON)). + Once() + })(t) + + bb := bitBucketAPI{ + uri: srv.URL(), + authToken: "test-token", + timeout: time.Second, + project: "proj", + repo: "repo", } - for _, tc := range testCases { - t.Run(tc.description, func(t *testing.T) { - slog.SetDefault(slogt.New(t)) - - srv := tc.mock(t) - - r := reporter.NewBitBucketReporter( - "v0.0.0", - srv.URL(), - time.Second, - "token", - "proj", - "repo", - 50, - tc.showDuplicates, - tc.gitCmd) - summary := reporter.NewSummary(tc.reports) - err := r.Submit(t.Context(), summary) - - if e := tc.errorHandler(err); e != nil { - t.Errorf("error check failure: %s", e) - return + pr, err := bb.findPullRequestForBranch("feature", "abc") + require.NoError(t, err) + require.Nil(t, pr) +} + +func TestGetPullRequestCommentsWhoamiError(t *testing.T) { + slog.SetDefault(slogt.New(t)) + + // Verifies that getPullRequestComments returns error when whoami fails. + srv := httpmock.New(func(s *httpmock.Server) { + s.ExpectGet("/plugins/servlet/applinks/whoami"). + ReturnCode(http.StatusInternalServerError). + Once() + })(t) + + bb := bitBucketAPI{ + uri: srv.URL(), + authToken: "test-token", + timeout: time.Second, + project: "proj", + repo: "repo", + } + + pr := &bitBucketPR{ID: 1} + _, err := bb.getPullRequestComments(pr) + require.EqualError(t, err, "GET request failed") +} + +func TestGetPullRequestCommentsActivitiesRequestError(t *testing.T) { + slog.SetDefault(slogt.New(t)) + + // Verifies that getPullRequestComments returns error when the activities request fails. + srv := httpmock.New(func(s *httpmock.Server) { + s.ExpectGet("/plugins/servlet/applinks/whoami"). + Return("testuser"). + Once() + s.ExpectGet("/rest/api/latest/projects/proj/repos/repo/pull-requests/1/activities?start=0"). + ReturnCode(http.StatusInternalServerError). + Once() + })(t) + + bb := bitBucketAPI{ + uri: srv.URL(), + authToken: "test-token", + timeout: time.Second, + project: "proj", + repo: "repo", + } + + pr := &bitBucketPR{ID: 1} + _, err := bb.getPullRequestComments(pr) + require.EqualError(t, err, "GET request failed") +} + +func TestNewBitBucketReporter(t *testing.T) { + slog.SetDefault(slogt.New(t)) + + // Verifies that the constructor initializes all fields correctly. + gitCmd := func(_ ...string) ([]byte, error) { return nil, nil } + bb := NewBitBucketReporter( + "http://localhost", + time.Minute, + "token", + "proj", + "repo", + 50, + gitCmd, + ) + require.Equal(t, "BitBucket", bb.Describe()) + require.Equal(t, 50, bb.maxComments) + require.NotNil(t, bb.api) + require.Equal(t, "http://localhost", bb.api.uri) + require.Equal(t, "token", bb.api.authToken) + require.Equal(t, "proj", bb.api.project) + require.Equal(t, "repo", bb.api.repo) + require.Equal(t, time.Minute, bb.api.timeout) +} + +func TestBitBucketReporterDestinationsAPIError(t *testing.T) { + slog.SetDefault(slogt.New(t)) + + // Verifies that Destinations returns error when findPullRequestForBranch fails. + srv := httpmock.New(func(s *httpmock.Server) { + s.ExpectGet("/rest/api/1.0/projects/proj/repos/repo/commits/abc123/pull-requests?start=0"). + ReturnCode(http.StatusInternalServerError). + Once() + })(t) + + bb := BitBucketReporter{ + api: newBitBucketAPI( + srv.URL(), time.Second, + "token", "proj", "repo", + ), + gitCmd: func(args ...string) ([]byte, error) { + if args[0] == "rev-parse" && args[1] == "--verify" { + return []byte("abc123"), nil } - }) + if args[0] == "rev-parse" && args[1] == "--abbrev-ref" { + return []byte("feature"), nil + } + return nil, nil + }, + } + _, err := bb.Destinations(t.Context()) + require.ErrorContains(t, err, "failed to get open pull requests from BitBucket") +} + +func TestBitBucketReporterSummary(t *testing.T) { + // Verifies that Summary always returns nil. + bb := BitBucketReporter{} + err := bb.Summary(context.Background(), nil, Summary{}, nil, nil) + require.NoError(t, err) +} + +func TestBitBucketReporterListError(t *testing.T) { + slog.SetDefault(slogt.New(t)) + + // Verifies that List returns error when getPullRequestComments fails. + srv := httpmock.New(func(s *httpmock.Server) { + s.ExpectGet("/plugins/servlet/applinks/whoami"). + ReturnCode(http.StatusInternalServerError). + Once() + })(t) + + bb := BitBucketReporter{ + api: newBitBucketAPI( + srv.URL(), time.Second, + "token", "proj", "repo", + ), } + pr := &bitBucketPR{ID: 1} + _, err := bb.List(t.Context(), pr) + require.EqualError(t, err, "GET request failed") } diff --git a/internal/reporter/comments.go b/internal/reporter/comments.go index 14f7ee8e..10ddf2a5 100644 --- a/internal/reporter/comments.go +++ b/internal/reporter/comments.go @@ -216,23 +216,6 @@ func dedupReports(src []Report, showDuplicates bool) (dst [][]Report) { return dst } -func identicalDetails(src []Report) bool { - if len(src) <= 1 { - return false - } - var details string - for _, report := range src { - if details == "" { - details = report.Problem.Details - continue - } - if details != report.Problem.Details { - return false - } - } - return true -} - func problemIcon(s checks.Severity) string { // nolint:exhaustive switch s {