Repository: kubernetes-sigs/prow

Issue:
loop of tests
We have a weird issue, and actually, a bit funny.

We're using two prows in our repository, and we have tests defined in both of them.

When trying to retest a specific test using the `/test test-name` command, one of the prows always reacts by commenting that this test is not known, and offers a list of valid tests. 

The list of available tests are formed as a full `/test` command, in a code block, e.g.
```
/test ci-index-hco-bundle
```
```
/test ci-index-hco-upgrade-operator-sdk-bundle
```
```
/test hco-e2e-consecutive-operator-sdk-upgrades-aws
```
```
/test hco-e2e-consecutive-operator-sdk-upgrades-azure
```
```
/test hco-e2e-kv-smoke-azure
```

This is understandable and we are used to it.

But lately this behavior was changed a bit: the other prow refers these comments as (many) retest requests, and it does not know them, because these tests are defined in the first prow. And now it's the 2nd prow turn to comment with it's own list of tests - and we get a long loop that I don't know what cause it to finally stop. Anyway, the result is a huge PE discussion that it hard to open in a web browser, and sometimes it's even fails to open the PR page.

See example, here: https://github.com/kubevirt/hyperconverged-cluster-operator/pull/3476#issuecomment-2801038871 

I wonder if we can re-ignore the `/test` commands, if the are within a code block, e.g. if they are a regular text in the beginning of a new line?

Discussion:
@nunnatsa: The issue is caused by #323.

It changed the format of the commands in the comment, from
* /test something

to
```
/test something
```

Now, the `pjutil.TestHelpRe` regex (`(?m)^/test[ \t]*\?\s*$`), finds these available command blocks and refers them as re-test commands.@smg247: /area plugins@nunnatsa: /cc

Solution patch:
diff --git a/pkg/markdown/code_block.go b/pkg/markdown/code_block.go
new file mode 100644
index 000000000..f16ab9ced
--- /dev/null
+++ b/pkg/markdown/code_block.go
@@ -0,0 +1,27 @@
+/*
+Copyright 2025 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package markdown
+
+import (
+	"regexp"
+)
+
+var codeBlockRe = regexp.MustCompile("(?msU)^```.*(\n```\n|\n```$)|(?msU)^~~~.*(\n~~~\n|\n~~~$)")
+
+func DropCodeBlock(text string) string {
+	return codeBlockRe.ReplaceAllString(text, "")
+}
diff --git a/pkg/plugins/plugin-config-documented.yaml b/pkg/plugins/plugin-config-documented.yaml
index 597df0076..04fcaaff9 100644
--- a/pkg/plugins/plugin-config-documented.yaml
+++ b/pkg/plugins/plugin-config-documented.yaml
@@ -62,6 +62,16 @@ blunderbuss:
     # additional token per successful reviewer (and potentially more depending on
     # how many busy reviewers it had to pass over).
     use_status_availability: true
+    # WaitForStatus specifies whether to request reviews if the tide status indicates that
+    # the tests have passed but there are insufficient pull request reviews.
+    wait_for_status:
+        # Context name of the context to match on, defaults to "tide"
+        context: ' '
+        # Description regular expression to match the context description, defaults to
+        # "Not mergeable. (PullRequest is missing sufficient approving GitHub review\(s\)|Needs (lgtm|approved) label)"
+        description: ' '
+        # State is the state we want the context to be in before requesting reviews, e.g. "pending"
+        state: ' '
 branch_cleaner:
     # PreservedBranches is a map of org/repo branches
     # format:
diff --git a/pkg/plugins/trigger/generic-comment.go b/pkg/plugins/trigger/generic-comment.go
index 18eb21c39..796d2291c 100644
--- a/pkg/plugins/trigger/generic-comment.go
+++ b/pkg/plugins/trigger/generic-comment.go
@@ -26,6 +26,7 @@ import (
 	"sigs.k8s.io/prow/pkg/config"
 	"sigs.k8s.io/prow/pkg/github"
 	"sigs.k8s.io/prow/pkg/labels"
+	"sigs.k8s.io/prow/pkg/markdown"
 	"sigs.k8s.io/prow/pkg/pjutil"
 	"sigs.k8s.io/prow/pkg/plugins"
 )
@@ -57,14 +58,15 @@ func handleGenericComment(c Client, trigger plugins.Trigger, gc github.GenericCo
 	presubmits := getPresubmits(c.Logger, c.GitClient, c.Config, org+"/"+repo, refGetter.BaseSHA, refGetter.HeadSHA)
 
 	// Skip comments not germane to this plugin
-	if !pjutil.RetestRe.MatchString(gc.Body) &&
-		!pjutil.RetestRequiredRe.MatchString(gc.Body) &&
-		!pjutil.OkToTestRe.MatchString(gc.Body) &&
-		!pjutil.TestAllRe.MatchString(gc.Body) &&
-		!pjutil.MayNeedHelpComment(gc.Body) {
+	textToCheck := markdown.DropCodeBlock(gc.Body)
+	if !pjutil.RetestRe.MatchString(textToCheck) &&
+		!pjutil.RetestRequiredRe.MatchString(textToCheck) &&
+		!pjutil.OkToTestRe.MatchString(textToCheck) &&
+		!pjutil.TestAllRe.MatchString(textToCheck) &&
+		!pjutil.MayNeedHelpComment(textToCheck) {
 		matched := false
 		for _, presubmit := range presubmits {
-			matched = matched || presubmit.TriggerMatches(gc.Body)
+			matched = matched || presubmit.TriggerMatches(textToCheck)
 			if matched {
 				break
 			}
@@ -105,7 +107,7 @@ func handleGenericComment(c Client, trigger plugins.Trigger, gc github.GenericCo
 			return err
 		}
 	}
-	isOkToTest := HonorOkToTest(trigger) && pjutil.OkToTestRe.MatchString(gc.Body)
+	isOkToTest := HonorOkToTest(trigger) && pjutil.OkToTestRe.MatchString(textToCheck)
 	if isOkToTest && !github.HasLabel(labels.OkToTest, l) {
 		if err := c.GitHubClient.AddLabel(org, repo, number, labels.OkToTest); err != nil {
 			return err
@@ -130,16 +132,16 @@ func handleGenericComment(c Client, trigger plugins.Trigger, gc github.GenericCo
 	if err != nil {
 		return err
 	}
-	if needsHelp, note := pjutil.ShouldRespondWithHelp(gc.Body, len(toTest)); needsHelp {
+	if needsHelp, note := pjutil.ShouldRespondWithHelp(textToCheck, len(toTest)); needsHelp {
 		return addHelpComment(c.GitHubClient, gc.Body, org, repo, pr.Base.Ref, pr.Number, presubmits, gc.HTMLURL, commentAuthor, note, c.Logger)
 	}
 	// we want to be able to track re-tests separately from the general body of tests
 	additionalLabels := map[string]string{}
-	if pjutil.RetestRe.MatchString(gc.Body) || pjutil.RetestRequiredRe.MatchString(gc.Body) {
+	if pjutil.RetestRe.MatchString(textToCheck) || pjutil.RetestRequiredRe.MatchString(textToCheck) {
 		additionalLabels[kube.RetestLabel] = "true"
 	}
 	// run failed github actions
-	if trigger.TriggerGitHubWorkflows && (pjutil.RetestRe.MatchString(gc.Body) || pjutil.TestAllRe.MatchString(gc.Body)) {
+	if trigger.TriggerGitHubWorkflows && (pjutil.RetestRe.MatchString(textToCheck) || pjutil.TestAllRe.MatchString(textToCheck)) {
 		headSHA, err := refGetter.HeadSHA()
 		if err != nil {
 			c.Logger.Warnf("headSHA unavailable, failed github actions for pr will not be triggered: %v", pr)
diff --git a/pkg/spyglass/lenses/common/bindata.go b/pkg/spyglass/lenses/common/bindata.go
index 9d60237f8..31f10378a 100644
--- a/pkg/spyglass/lenses/common/bindata.go
+++ b/pkg/spyglass/lenses/common/bindata.go
@@ -1,6 +1,6 @@
 // Code generated by go-bindata. (@generated) DO NOT EDIT.
 
-// Package common generated by go-bindata.// sources:
+//Package common generated by go-bindata.// sources:
 // static/spyglass-lens.html
 package common
 
@@ -93,7 +93,7 @@ func staticSpyglassLensHtml() (*asset, error) {
 		return nil, err
 	}
 
-	info := bindataFileInfo{name: "static/spyglass-lens.html", size: 616, mode: os.FileMode(420), modTime: time.Unix(1715774759, 0)}
+	info := bindataFileInfo{name: "static/spyglass-lens.html", size: 616, mode: os.FileMode(420), modTime: time.Unix(1744640848, 0)}
 	a := &asset{bytes: bytes, info: info}
 	return a, nil
 }



Test patch:
diff --git a/pkg/markdown/code_block_test.go b/pkg/markdown/code_block_test.go
new file mode 100644
index 000000000..bb1e8945b
--- /dev/null
+++ b/pkg/markdown/code_block_test.go
@@ -0,0 +1,176 @@
+/*
+Copyright 2025 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package markdown_test
+
+import (
+	"strings"
+	"testing"
+
+	"sigs.k8s.io/prow/pkg/markdown"
+)
+
+type codeBlockTestCase struct {
+	testName     string
+	origText     string
+	expectedText string
+}
+
+const (
+	singleLineToStay = "should stay"
+	multiLinesToStay = `should stay
+this one should stay as well
+and this one too
+`
+
+	singleLineCodeBlock = "```" + `
+should be filtered out
+` + "```"
+	multiLinesCodeBlock = "```" + `
+should be filtered out
+this one should be filtered out as well
+and this one too
+` + "```\n"
+	codeBlockWithLanguage = "```shell" + `
+should be filtered out
+this one should be filtered out as well
+and this one too
+` + "```\n"
+)
+
+func TestDropCodeBlock(t *testing.T) {
+	tests := []codeBlockTestCase{
+		{
+			testName:     "no code block, single line",
+			origText:     singleLineToStay,
+			expectedText: singleLineToStay,
+		},
+		{
+			testName:     "no code block, multi-lines",
+			origText:     multiLinesToStay,
+			expectedText: multiLinesToStay,
+		},
+		{
+			testName:     "one single-line block",
+			origText:     singleLineCodeBlock,
+			expectedText: "",
+		},
+		{
+			testName:     "one multi-line block",
+			origText:     multiLinesCodeBlock,
+			expectedText: "",
+		},
+		{
+			testName:     "one multi-line tilda block",
+			origText:     strings.ReplaceAll(multiLinesCodeBlock, "```", "~~~"),
+			expectedText: "",
+		},
+		{
+			testName:     "one multi-line block, wrapped with text",
+			origText:     multiLinesToStay + multiLinesCodeBlock + multiLinesToStay,
+			expectedText: multiLinesToStay + multiLinesToStay,
+		},
+		{
+			testName: "few multi-line blocks",
+			origText: multiLinesCodeBlock +
+				multiLinesCodeBlock +
+				multiLinesCodeBlock +
+				multiLinesCodeBlock,
+			expectedText: "",
+		},
+		{
+			testName: "few multi-line blocks, no new line at the end",
+			origText: multiLinesCodeBlock +
+				multiLinesCodeBlock +
+				multiLinesCodeBlock +
+				multiLinesCodeBlock[:len(multiLinesCodeBlock)-1],
+			expectedText: "",
+		},
+		{
+			testName: "few multi-line blocks, some wrapped with test",
+			origText: singleLineToStay + "\n" +
+				multiLinesCodeBlock +
+				multiLinesCodeBlock +
+				multiLinesToStay +
+				multiLinesCodeBlock +
+				multiLinesToStay +
+				multiLinesCodeBlock,
+			expectedText: singleLineToStay + "\n" +
+				multiLinesToStay +
+				multiLinesToStay,
+		},
+		{
+			testName: "few multi-line tilda blocks, some wrapped with test",
+			origText: strings.ReplaceAll(singleLineToStay+"\n"+
+				multiLinesCodeBlock+
+				multiLinesCodeBlock+
+				multiLinesToStay+
+				multiLinesCodeBlock+
+				multiLinesToStay+
+				multiLinesCodeBlock, "```", "~~~"),
+			expectedText: singleLineToStay + "\n" +
+				multiLinesToStay +
+				multiLinesToStay,
+		},
+		{
+			testName: "should filter out code blocks with language",
+			origText: singleLineToStay + "\n" +
+				multiLinesCodeBlock +
+				codeBlockWithLanguage +
+				multiLinesToStay +
+				multiLinesCodeBlock +
+				multiLinesToStay +
+				codeBlockWithLanguage,
+			expectedText: singleLineToStay + "\n" +
+				multiLinesToStay +
+				multiLinesToStay,
+		},
+		{
+			testName: "invalid block: extra test at the end",
+			origText: "```\n" +
+				"this one is here to stay\n" +
+				"``` extra text",
+			expectedText: "```\n" +
+				"this one is here to stay\n" +
+				"``` extra text",
+		},
+		{
+			testName: "invalid block: extra test at the end",
+			origText: "```\n" +
+				"this one is here to stay\n" +
+				"``` extra text\n",
+			expectedText: "```\n" +
+				"this one is here to stay\n" +
+				"``` extra text\n",
+		},
+		{
+			testName: "invalid block: not closed",
+			origText: "```\n" +
+				"this one is here to stay",
+			expectedText: "```\n" +
+				"this one is here to stay",
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.testName, func(t *testing.T) {
+			result := markdown.DropCodeBlock(test.origText)
+			if result != test.expectedText {
+				t.Errorf("for the original text of\n%s\n\nexpected: %q\ngot:      %q", test.origText, test.expectedText, result)
+			}
+		})
+	}
+}
diff --git a/pkg/plugins/trigger/generic-comment_test.go b/pkg/plugins/trigger/generic-comment_test.go
index 0647c4b30..7888f1d9c 100644
--- a/pkg/plugins/trigger/generic-comment_test.go
+++ b/pkg/plugins/trigger/generic-comment_test.go
@@ -45,6 +45,8 @@ func issueLabels(labels ...string) []string {
 	return ls
 }
 
+const shouldNotAddComment = "<none>"
+
 type testcase struct {
 	name string
 
@@ -996,6 +998,40 @@ func TestHandleGenericComment(t *testing.T) {
 			},
 			AddedComment: helpComment + "Use `/test all` to run all jobs.",
 		},
+		{
+			name:   `ignore "/test ?" in code block`,
+			Author: "trusted-member",
+			Body:   produceCodeBlock("/test ?", false),
+			State:  "open",
+			IsPR:   true,
+			Presubmits: map[string][]config.Presubmit{
+				"org/repo": {
+					{
+						JobBase: config.JobBase{
+							Name: "job",
+						},
+						AlwaysRun: true,
+						Reporter: config.Reporter{
+							Context: "pull-job",
+						},
+						Trigger:      `(?m)^/test (?:.*? )?job(?: .*?)?$`,
+						RerunCommand: `/test job`,
+					},
+					{
+						JobBase: config.JobBase{
+							Name: "jib",
+						},
+						AlwaysRun: true,
+						Reporter: config.Reporter{
+							Context: "pull-jib",
+						},
+						Trigger:      `(?m)^/test (?:.*? )?jib(?: .*?)?$`,
+						RerunCommand: `/test jib`,
+					},
+				},
+			},
+			AddedComment: shouldNotAddComment,
+		},
 		{
 			name:   `help command "/test ?" uses unique RerunCommand field of presubmits`,
 			Author: "trusted-member",
@@ -1050,6 +1086,22 @@ func TestHandleGenericComment(t *testing.T) {
 			IsPR:         true,
 			AddedComment: pjutil.TestWithoutTargetNote + helpComment + helpTestAllWithJobsComment,
 		},
+		{
+			name:         "ignore `/test` with no target results in a code block",
+			Author:       "trusted-member",
+			Body:         "```\n/test\n```",
+			State:        "open",
+			IsPR:         true,
+			AddedComment: shouldNotAddComment,
+		},
+		{
+			name:         "ignore `/test` with no target results in a tilda code block",
+			Author:       "trusted-member",
+			Body:         "~~~\n/test\n~~~",
+			State:        "open",
+			IsPR:         true,
+			AddedComment: shouldNotAddComment,
+		},
 		{
 			name:         "/test with no target but ? in the next line results in an invalid test command message",
 			Author:       "trusted-member",
@@ -1066,6 +1118,14 @@ func TestHandleGenericComment(t *testing.T) {
 			IsPR:         true,
 			AddedComment: pjutil.RetestWithTargetNote + helpComment + helpTestAllWithJobsComment,
 		},
+		{
+			name:         "/retest with trailing words results in a code block",
+			Author:       "trusted-member",
+			Body:         produceCodeBlock("/retest FOO", false),
+			State:        "open",
+			IsPR:         true,
+			AddedComment: shouldNotAddComment,
+		},
 		{
 			name:          "/retest without target but with lines following it, is valid",
 			Author:        "trusted-member",
@@ -1083,6 +1143,52 @@ func TestHandleGenericComment(t *testing.T) {
 			IsPR:         true,
 			AddedComment: pjutil.TargetNotFoundNote + helpComment + helpTestAllWithJobsComment,
 		},
+		{
+			name:         "/test with unknown target results in code block. Should be ignored",
+			Author:       "trusted-member",
+			Body:         produceCodeBlock("/test FOO", false),
+			State:        "open",
+			IsPR:         true,
+			AddedComment: shouldNotAddComment,
+		},
+		{
+			name:         "two `/test` with unknown target results. One in a code block, and one is not.",
+			Author:       "trusted-member",
+			Body:         produceCodeBlock("/test FOO", true) + "/test BAR",
+			State:        "open",
+			IsPR:         true,
+			AddedComment: pjutil.TargetNotFoundNote + helpComment + helpTestAllWithJobsComment,
+		},
+		{
+			name:         "two `/test` with unknown target results. One out of a code block, and one is inside.",
+			Author:       "trusted-member",
+			Body:         "/test FOO\n" + produceCodeBlock("/test BAR", false),
+			State:        "open",
+			IsPR:         true,
+			AddedComment: pjutil.TargetNotFoundNote + helpComment + helpTestAllWithJobsComment,
+		},
+		{
+			name:   "multiple code blocks; should be ignored.",
+			Author: "trusted-member",
+			Body: produceCodeBlock("/test FOO", true) +
+				produceCodeBlock("/test BAR", true) +
+				produceCodeBlock("/test BAZ", false),
+			State:        "open",
+			IsPR:         true,
+			AddedComment: shouldNotAddComment,
+		},
+		{
+			name:   "/test mixed with multiple code blocks; should be ignored.",
+			Author: "trusted-member",
+			Body: produceCodeBlock("/test FOO", true) + // code block
+				produceCodeBlock("/test BAR", true) + // code block
+				"/test BAZ\n" + // /test as a regular text
+				"Will trigger the help message.\n" + // some regular text
+				produceCodeBlock("/test QUX", false), // code block
+			State:        "open",
+			IsPR:         true,
+			AddedComment: pjutil.TargetNotFoundNote + helpComment + helpTestAllWithJobsComment,
+		},
 		{
 			name:   "help comment should list only eligible jobs under '/test all'",
 			Author: "trusted-member",
@@ -1150,6 +1256,40 @@ func TestHandleGenericComment(t *testing.T) {
 			},
 			AddedComment: pjutil.ThereAreNoTestAllJobsNote + helpComment,
 		},
+		{
+			name:   "when no jobs can be run with /test all, ignore if inside a code block",
+			Author: "trusted-member",
+			Body:   produceCodeBlock("/test all", false),
+			State:  "open",
+			IsPR:   true,
+			Presubmits: map[string][]config.Presubmit{
+				"org/repo": {
+					{
+						JobBase: config.JobBase{
+							Name: "job",
+						},
+						AlwaysRun: false,
+						Reporter: config.Reporter{
+							Context: "pull-job",
+						},
+						Trigger:      `(?m)^/test job$`,
+						RerunCommand: `/test job`,
+					},
+					{
+						JobBase: config.JobBase{
+							Name: "jib",
+						},
+						AlwaysRun: false,
+						Reporter: config.Reporter{
+							Context: "pull-jib",
+						},
+						Trigger:      `(?m)^/test jib$`,
+						RerunCommand: `/test jib`,
+					},
+				},
+			},
+			AddedComment: shouldNotAddComment,
+		},
 		{
 			name:   "available presubmits should not list those excluded by branch",
 			Author: "trusted-member",
@@ -1386,12 +1526,18 @@ func validate(t *testing.T, actions []clienttesting.Action, g *fakegithub.FakeCl
 		t.Errorf("expected %q to be removed, got %q", tc.RemovedLabels, g.IssueLabelsRemoved)
 	}
 	if tc.AddedComment != "" {
-		if len(g.IssueComments[0]) == 0 {
-			t.Errorf("expected the comments to contain %s, got no comments", tc.AddedComment)
-		}
-		for _, c := range g.IssueComments[0] {
-			if !strings.Contains(c.Body, tc.AddedComment) {
-				t.Errorf("expected the comment to contain %s, got %s", tc.AddedComment, c.Body)
+		if tc.AddedComment == shouldNotAddComment {
+			if len(g.IssueComments[0]) != 0 {
+				t.Errorf("expected no comments, got %v", g.IssueComments[0])
+			}
+		} else {
+			if len(g.IssueComments[0]) == 0 {
+				t.Errorf("expected the comments to contain %s, got no comments", tc.AddedComment)
+			}
+			for _, c := range g.IssueComments[0] {
+				if !strings.Contains(c.Body, tc.AddedComment) {
+					t.Errorf("expected the comment to contain %s, got %s", tc.AddedComment, c.Body)
+				}
 			}
 		}
 	}
@@ -1481,3 +1627,12 @@ func TestRetestFilter(t *testing.T) {
 		})
 	}
 }
+
+func produceCodeBlock(text string, withNewLine bool) string {
+	newline := ""
+	if withNewLine {
+		newline = "\n"
+	}
+
+	return fmt.Sprintf("```\n%s\n```%s", text, newline)
+}