Repository: kubernetes-sigs/prow

Issue:
PR with "Change requested" leads to Tide repeatedly attempting MERGE
<!-- Please use this template while reporting a bug and provide as much info as possible. Not doing so may result in your bug not being addressed in a timely manner. Thanks!-->


**What happened**:

For a Github PR that has passed all checks but has the `Change requested` state, Tide repeatedly attempts to MERGE but fails (presumably rejected by Github due to the `Change requested` state).

**What you expected to happen**:

I would expect Prow/Tide to be aware of the `Change requested` state and that it is blocking - and therefore not try to perform MERGE.

**How to reproduce it (as minimally and precisely as possible)**:

- Create a pull request
- Request changes on the PR
- Pass all other checks (e.g. presubmits, lgtm, approve, etc)
- Observe that Tide repeatedly attempts to perform MERGE and fails

**Please provide links to example occurrences, if any**:

Here is an example PR and tide history:
- https://github.com/GoogleContainerTools/kpt-config-sync/pull/210
- https://oss.gprow.dev/tide-history?repo=GoogleContainerTools%2Fkpt-config-sync&branch=main&pull=210

**Anything else we need to know?**:
N/A

---

Copied over from https://github.com/kubernetes/test-infra/issues/27750

Hints from issue comments:
@petr-muller: Copying some relevant information here from a [previous attempt to fix](https://github.com/kubernetes-sigs/prow/pull/270) this via approve plugin:

Many advanced branch protection features enforcing PR merge eligibility are younger than Tide and the big Prow orgs (kube, openshift) are mostly not using them. Generally Tide expects a PR to be mergeable once it passes Tide's criteria (query match + required jobs pass) and considers inability to merge to be "[user errors](https://github.com/kubernetes-sigs/prow/blob/7c1e4e90b5121d1c4a5547f069d70053707e45e5/pkg/tide/github.go#L274)".

I think that in today's world this does not stand. I'd prefer the solution to be

1. Tide to properly surface any occurrences when it thinks it may merge but is disallowed to do so
2. Tide to check as many mergeability properties as possible in its [isAllowedToMerge](https://github.com/kubernetes-sigs/prow/blame/7c1e4e90b5121d1c4a5547f069d70053707e45e5/pkg/tide/github.go#L520) method to avoid thinking it may merge a PR when GH would disallow it

It is not entirely straightforward. 1) is a problem because the status context (which right now is the only PR-facing Tide user interface, maybe with the exception of Tide PR dashboard which people don't really use that much) is controlled by a separate controller than the one that actually tries to merge. 2) is also not easy because checking mergeability for BP would require Tide asking about more properties of the PR (and possibly the repo, to check BP requirements).@k8s-triage-robot: The Kubernetes project currently lacks enough contributors to adequately respond to all issues.

This bot triages un-triaged issues according to the following rules:
- After 90d of inactivity, `lifecycle/stale` is applied
- After 30d of inactivity since `lifecycle/stale` was applied, `lifecycle/rotten` is applied
- After 30d of inactivity since `lifecycle/rotten` was applied, the issue is closed

You can:
- Mark this issue as fresh with `/remove-lifecycle stale`
- Close this issue with `/close`
- Offer to help out with [Issue Triage][1]

Please send feedback to sig-contributor-experience at [kubernetes/community](https://github.com/kubernetes/community).

/lifecycle stale

[1]: https://www.kubernetes.dev/docs/guide/issue-triage/@k8s-triage-robot: The Kubernetes project currently lacks enough active contributors to adequately respond to all issues.

This bot triages un-triaged issues according to the following rules:
- After 90d of inactivity, `lifecycle/stale` is applied
- After 30d of inactivity since `lifecycle/stale` was applied, `lifecycle/rotten` is applied
- After 30d of inactivity since `lifecycle/rotten` was applied, the issue is closed

You can:
- Mark this issue as fresh with `/remove-lifecycle rotten`
- Close this issue with `/close`
- Offer to help out with [Issue Triage][1]

Please send feedback to sig-contributor-experience at [kubernetes/community](https://github.com/kubernetes/community).

/lifecycle rotten

[1]: https://www.kubernetes.dev/docs/guide/issue-triage/@petr-muller: /remove-lifecycle rotten@petr-muller: /area tide@k8s-triage-robot: The Kubernetes project currently lacks enough contributors to adequately respond to all issues.

This bot triages un-triaged issues according to the following rules:
- After 90d of inactivity, `lifecycle/stale` is applied
- After 30d of inactivity since `lifecycle/stale` was applied, `lifecycle/rotten` is applied
- After 30d of inactivity since `lifecycle/rotten` was applied, the issue is closed

You can:
- Mark this issue as fresh with `/remove-lifecycle stale`
- Close this issue with `/close`
- Offer to help out with [Issue Triage][1]

Please send feedback to sig-contributor-experience at [kubernetes/community](https://github.com/kubernetes/community).

/lifecycle stale

[1]: https://www.kubernetes.dev/docs/guide/issue-triage/@k8s-triage-robot: The Kubernetes project currently lacks enough active contributors to adequately respond to all issues.

This bot triages un-triaged issues according to the following rules:
- After 90d of inactivity, `lifecycle/stale` is applied
- After 30d of inactivity since `lifecycle/stale` was applied, `lifecycle/rotten` is applied
- After 30d of inactivity since `lifecycle/rotten` was applied, the issue is closed

You can:
- Mark this issue as fresh with `/remove-lifecycle rotten`
- Close this issue with `/close`
- Offer to help out with [Issue Triage][1]

Please send feedback to sig-contributor-experience at [kubernetes/community](https://github.com/kubernetes/community).

/lifecycle rotten

[1]: https://www.kubernetes.dev/docs/guide/issue-triage/@petr-muller: /remove-lifecycle rotten
/lifecycle frozen

This is something that needs to be fixed

Solution patch:
diff --git a/pkg/tide/github.go b/pkg/tide/github.go
index f5a42997b..d0cd87464 100644
--- a/pkg/tide/github.go
+++ b/pkg/tide/github.go
@@ -632,6 +632,11 @@ func (m *mergeChecker) isAllowedToMerge(crc *CodeReviewCommon) (string, error) {
 	} else if !allowed {
 		return fmt.Sprintf("Merge type %q disallowed by repo settings", *mergeMethod), nil
 	}
+	// Check GitHub's mergeStateStatus which reflects all GitHub-side merge blocking conditions
+	// including branch protection rules, rulesets, required reviews, status checks, etc.
+	if pr.MergeStateStatus == "BLOCKED" {
+		return "PR is blocked from merging by GitHub (check branch protection, required reviews, or rulesets)", nil
+	}
 	return "", nil
 }
 
diff --git a/pkg/tide/status.go b/pkg/tide/status.go
index 17901811e..c4bc87550 100644
--- a/pkg/tide/status.go
+++ b/pkg/tide/status.go
@@ -253,13 +253,19 @@ func requirementDiff(pr *PullRequest, q *config.TideQuery, cc contextChecker) (s
 			desc = fmt.Sprintf(" Jobs %s have not succeeded.", strings.Join(trunced, ", "))
 		}
 	}
-
 	if q.ReviewApprovedRequired && pr.ReviewDecision != githubql.PullRequestReviewDecisionApproved {
 		diff += 50
 		if desc == "" {
 			desc = " PullRequest is missing sufficient approving GitHub review(s)"
 		}
 	}
+	// Check GitHub's mergeStateStatus which reflects all GitHub-side blocking conditions
+	if pr.MergeStateStatus == "BLOCKED" {
+		diff += 100
+		if desc == "" {
+			desc = " Blocked by GitHub (branch rulesets or protection)"
+		}
+	}
 	return desc, diff
 }
 
diff --git a/pkg/tide/tide.go b/pkg/tide/tide.go
index c4e974412..1900fe2ec 100644
--- a/pkg/tide/tide.go
+++ b/pkg/tide/tide.go
@@ -1920,10 +1920,11 @@ type PullRequest struct {
 		Name   githubql.String
 		Prefix githubql.String
 	}
-	HeadRefName  githubql.String `graphql:"headRefName"`
-	HeadRefOID   githubql.String `graphql:"headRefOid"`
-	Mergeable    githubql.MergeableState
-	CanBeRebased githubql.Boolean `graphql:"canBeRebased"`
+	HeadRefName     githubql.String `graphql:"headRefName"`
+	HeadRefOID      githubql.String `graphql:"headRefOid"`
+	Mergeable       githubql.MergeableState
+	MergeStateStatus githubql.String `graphql:"mergeStateStatus"`
+	CanBeRebased    githubql.Boolean `graphql:"canBeRebased"`
 	Repository   struct {
 		Name          githubql.String
 		NameWithOwner githubql.String



Test patch:
diff --git a/pkg/tide/status_test.go b/pkg/tide/status_test.go
index 082d2f03e..2748d9818 100644
--- a/pkg/tide/status_test.go
+++ b/pkg/tide/status_test.go
@@ -870,6 +870,536 @@ func TestExpectedStatus(t *testing.T) {
 	}
 }
 
+func TestRequirementDiff(t *testing.T) {
+	testCases := []struct {
+		name                   string
+		prLabels               []string
+		prAuthor               string
+		prMilestone            *Milestone
+		prBaseBranch           string
+		prContexts             []Context
+		prCheckRuns            []CheckRun
+		reviewDecision         githubql.PullRequestReviewDecision
+		mergeStateStatus       string
+		queryLabels            []string
+		queryForbiddenLabels   []string
+		queryAuthor            string
+		queryMilestone         string
+		queryExcludedBranches  []string
+		queryIncludedBranches  []string
+		reviewApprovedRequired bool
+		expectedDiff           int
+		expectedDescContains   string
+	}{
+		// MergeStateStatus tests
+		{
+			name:                   "Blocked merge state with changes requested",
+			reviewDecision:         githubql.PullRequestReviewDecisionChangesRequested,
+			mergeStateStatus:       "BLOCKED",
+			reviewApprovedRequired: false,
+			expectedDiff:           100,
+			expectedDescContains:   "Blocked by GitHub (branch rulesets or protection)",
+		},
+		{
+			name:                   "Blocked merge state with specific review decision, review required",
+			reviewDecision:         githubql.PullRequestReviewDecisionChangesRequested,
+			mergeStateStatus:       "BLOCKED",
+			reviewApprovedRequired: true,
+			expectedDiff:           150,
+			expectedDescContains:   "PullRequest is missing sufficient approving GitHub review(s)",
+		},
+		{
+			name:                   "Blocked merge state without specific review decision",
+			mergeStateStatus:       "BLOCKED",
+			reviewApprovedRequired: false,
+			expectedDiff:           100,
+			expectedDescContains:   "Blocked by GitHub (branch rulesets or protection)",
+		},
+		{
+			name:                   "Clean merge state allows merge",
+			mergeStateStatus:       "CLEAN",
+			reviewApprovedRequired: false,
+			expectedDiff:           0,
+			expectedDescContains:   "",
+		},
+		{
+			name:                   "Missing approval when required",
+			reviewDecision:         "",
+			reviewApprovedRequired: true,
+			expectedDiff:           50,
+			expectedDescContains:   "missing sufficient approving",
+		},
+		{
+			name:                   "Approved review passes",
+			reviewDecision:         githubql.PullRequestReviewDecisionApproved,
+			reviewApprovedRequired: true,
+			expectedDiff:           0,
+			expectedDescContains:   "",
+		},
+		{
+			name:                   "No review decision without requirement passes",
+			reviewDecision:         "",
+			reviewApprovedRequired: false,
+			expectedDiff:           0,
+			expectedDescContains:   "",
+		},
+		// Branch filtering tests
+		{
+			name:                  "Branch in excluded list",
+			prBaseBranch:          "release-1.0",
+			queryExcludedBranches: []string{"release-1.0", "release-2.0"},
+			expectedDiff:          2000,
+			expectedDescContains:  "Merging to branch release-1.0 is forbidden",
+		},
+		{
+			name:                  "Branch not in excluded list",
+			prBaseBranch:          "main",
+			queryExcludedBranches: []string{"release-1.0", "release-2.0"},
+			expectedDiff:          0,
+			expectedDescContains:  "",
+		},
+		{
+			name:                  "Branch not in included list",
+			prBaseBranch:          "feature-branch",
+			queryIncludedBranches: []string{"main", "develop"},
+			expectedDiff:          2000,
+			expectedDescContains:  "Merging to branch feature-branch is forbidden",
+		},
+		{
+			name:                  "Branch in included list",
+			prBaseBranch:          "main",
+			queryIncludedBranches: []string{"main", "develop"},
+			expectedDiff:          0,
+			expectedDescContains:  "",
+		},
+		// Author tests
+		{
+			name:                 "Matching author",
+			prAuthor:             "alice",
+			queryAuthor:          "alice",
+			expectedDiff:         0,
+			expectedDescContains: "",
+		},
+		{
+			name:                 "Non-matching author",
+			prAuthor:             "bob",
+			queryAuthor:          "alice",
+			expectedDiff:         1000,
+			expectedDescContains: "Must be by author alice",
+		},
+		{
+			name:                 "No author requirement",
+			prAuthor:             "anyone",
+			queryAuthor:          "",
+			expectedDiff:         0,
+			expectedDescContains: "",
+		},
+		// Milestone tests
+		{
+			name:                 "Matching milestone",
+			prMilestone:          &Milestone{Title: githubql.String("v1.0")},
+			queryMilestone:       "v1.0",
+			expectedDiff:         0,
+			expectedDescContains: "",
+		},
+		{
+			name:                 "Non-matching milestone",
+			prMilestone:          &Milestone{Title: githubql.String("v1.1")},
+			queryMilestone:       "v1.0",
+			expectedDiff:         100,
+			expectedDescContains: "Must be in milestone v1.0",
+		},
+		{
+			name:                 "PR has no milestone but query requires one",
+			prMilestone:          nil,
+			queryMilestone:       "v1.0",
+			expectedDiff:         100,
+			expectedDescContains: "Must be in milestone v1.0",
+		},
+		{
+			name:                 "No milestone requirement",
+			prMilestone:          &Milestone{Title: githubql.String("v1.0")},
+			queryMilestone:       "",
+			expectedDiff:         0,
+			expectedDescContains: "",
+		},
+		// Label tests
+		{
+			name:                 "All required labels present",
+			prLabels:             []string{"lgtm", "approved"},
+			queryLabels:          []string{"lgtm", "approved"},
+			expectedDiff:         0,
+			expectedDescContains: "",
+		},
+		{
+			name:                 "Missing one required label",
+			prLabels:             []string{"lgtm"},
+			queryLabels:          []string{"lgtm", "approved"},
+			expectedDiff:         1,
+			expectedDescContains: "Needs approved label",
+		},
+		{
+			name:                 "Missing multiple required labels",
+			prLabels:             []string{},
+			queryLabels:          []string{"lgtm", "approved"},
+			expectedDiff:         2,
+			expectedDescContains: "Needs approved, lgtm labels",
+		},
+		{
+			name:                 "Has forbidden label",
+			prLabels:             []string{"lgtm", "do-not-merge"},
+			queryForbiddenLabels: []string{"do-not-merge"},
+			expectedDiff:         1,
+			expectedDescContains: "Should not have do-not-merge label",
+		},
+		{
+			name:                 "Has multiple forbidden labels",
+			prLabels:             []string{"lgtm", "do-not-merge", "hold"},
+			queryForbiddenLabels: []string{"do-not-merge", "hold"},
+			expectedDiff:         2,
+			expectedDescContains: "Should not have do-not-merge, hold labels",
+		},
+		{
+			name:                 "Alternative labels (one of several)",
+			prLabels:             []string{"approved"},
+			queryLabels:          []string{"lgtm,approved"},
+			expectedDiff:         0,
+			expectedDescContains: "",
+		},
+		{
+			name:                 "Alternative labels with none present",
+			prLabels:             []string{},
+			queryLabels:          []string{"lgtm,approved"},
+			expectedDiff:         1,
+			expectedDescContains: "Needs lgtm or approved label",
+		},
+		// Context tests
+		{
+			name: "All contexts successful",
+			prContexts: []Context{
+				{Context: githubql.String("ci/test"), State: githubql.StatusStateSuccess},
+				{Context: githubql.String("ci/build"), State: githubql.StatusStateSuccess},
+			},
+			expectedDiff:         0,
+			expectedDescContains: "",
+		},
+		{
+			name: "One failed context",
+			prContexts: []Context{
+				{Context: githubql.String("ci/test"), State: githubql.StatusStateFailure},
+			},
+			expectedDiff:         1,
+			expectedDescContains: "Job ci/test has not succeeded",
+		},
+		{
+			name: "Multiple failed contexts",
+			prContexts: []Context{
+				{Context: githubql.String("ci/test"), State: githubql.StatusStateFailure},
+				{Context: githubql.String("ci/build"), State: githubql.StatusStateError},
+			},
+			expectedDiff:         2,
+			expectedDescContains: "Jobs ci/build, ci/test have not succeeded",
+		},
+		{
+			name: "Pending context counts as failed",
+			prContexts: []Context{
+				{Context: githubql.String("ci/test"), State: githubql.StatusStatePending},
+			},
+			expectedDiff:         1,
+			expectedDescContains: "Job ci/test has not succeeded",
+		},
+		{
+			name: "Successful checkrun",
+			prCheckRuns: []CheckRun{
+				{Name: githubql.String("test-job"), Status: githubql.String(githubql.CheckStatusStateCompleted), Conclusion: githubql.String(githubql.StatusStateSuccess)},
+			},
+			expectedDiff:         0,
+			expectedDescContains: "",
+		},
+		{
+			name: "Failed checkrun",
+			prCheckRuns: []CheckRun{
+				{Name: githubql.String("test-job"), Status: githubql.String(githubql.CheckStatusStateCompleted), Conclusion: githubql.String(githubql.StatusStateFailure)},
+			},
+			expectedDiff:         1,
+			expectedDescContains: "Job test-job has not succeeded",
+		},
+		// Priority ordering test: branch takes precedence
+		{
+			name:                  "Branch mismatch takes precedence over other issues",
+			prBaseBranch:          "forbidden-branch",
+			queryExcludedBranches: []string{"forbidden-branch"},
+			prAuthor:              "wrong-author",
+			queryAuthor:           "correct-author",
+			prLabels:              []string{},
+			queryLabels:           []string{"lgtm"},
+			prContexts: []Context{
+				{Context: githubql.String("ci/test"), State: githubql.StatusStateFailure},
+			},
+			expectedDiff:         3002, // 2000 (branch) + 1000 (author) + 1 (label) + 1 (context)
+			expectedDescContains: "Merging to branch forbidden-branch is forbidden",
+		},
+		// Author takes precedence over labels and contexts
+		{
+			name:        "Author mismatch takes precedence over labels and contexts",
+			prAuthor:    "wrong-author",
+			queryAuthor: "correct-author",
+			prLabels:    []string{},
+			queryLabels: []string{"lgtm"},
+			prContexts: []Context{
+				{Context: githubql.String("ci/test"), State: githubql.StatusStateFailure},
+			},
+			expectedDiff:         1002, // 1000 (author) + 1 (label) + 1 (context)
+			expectedDescContains: "Must be by author correct-author",
+		},
+		// Milestone takes precedence over labels and contexts
+		{
+			name:           "Milestone mismatch takes precedence over labels and contexts",
+			prMilestone:    &Milestone{Title: githubql.String("v1.1")},
+			queryMilestone: "v1.0",
+			prLabels:       []string{},
+			queryLabels:    []string{"lgtm"},
+			prContexts: []Context{
+				{Context: githubql.String("ci/test"), State: githubql.StatusStateFailure},
+			},
+			expectedDiff:         102, // 100 (milestone) + 1 (label) + 1 (context)
+			expectedDescContains: "Must be in milestone v1.0",
+		},
+		// Labels take precedence over contexts
+		{
+			name:        "Missing labels take precedence over failed contexts",
+			prLabels:    []string{},
+			queryLabels: []string{"lgtm"},
+			prContexts: []Context{
+				{Context: githubql.String("ci/test"), State: githubql.StatusStateFailure},
+			},
+			expectedDiff:         2, // 1 (label) + 1 (context)
+			expectedDescContains: "Needs lgtm label",
+		},
+		// Forbidden labels take precedence over contexts
+		{
+			name:                 "Forbidden labels take precedence over failed contexts",
+			prLabels:             []string{"do-not-merge"},
+			queryForbiddenLabels: []string{"do-not-merge"},
+			prContexts: []Context{
+				{Context: githubql.String("ci/test"), State: githubql.StatusStateFailure},
+			},
+			expectedDiff:         2, // 1 (forbidden label) + 1 (context)
+			expectedDescContains: "Should not have do-not-merge label",
+		},
+		// All aspects satisfied - perfect PR
+		{
+			name:                   "Complex: All requirements satisfied across all areas",
+			prBaseBranch:           "main",
+			queryIncludedBranches:  []string{"main", "develop"},
+			prAuthor:               "approved-contributor",
+			queryAuthor:            "approved-contributor",
+			prMilestone:            &Milestone{Title: githubql.String("v1.0")},
+			queryMilestone:         "v1.0",
+			prLabels:               []string{"lgtm", "approved", "size/small"},
+			queryLabels:            []string{"lgtm", "approved"},
+			queryForbiddenLabels:   []string{"do-not-merge", "hold"},
+			reviewDecision:         githubql.PullRequestReviewDecisionApproved,
+			reviewApprovedRequired: true,
+			prContexts: []Context{
+				{Context: githubql.String("ci/test"), State: githubql.StatusStateSuccess},
+				{Context: githubql.String("ci/lint"), State: githubql.StatusStateSuccess},
+			},
+			prCheckRuns: []CheckRun{
+				{Name: githubql.String("security-scan"), Status: githubql.String(githubql.CheckStatusStateCompleted), Conclusion: githubql.String(githubql.StatusStateSuccess)},
+				{Name: githubql.String("e2e-tests"), Status: githubql.String(githubql.CheckStatusStateCompleted), Conclusion: githubql.String(githubql.StatusStateSuccess)},
+			},
+			expectedDiff:         0,
+			expectedDescContains: "",
+		},
+		// All aspects with branch issue
+		{
+			name:                   "Complex: Multiple issues across all areas with branch taking precedence",
+			prBaseBranch:           "release-1.0",
+			queryExcludedBranches:  []string{"release-1.0"},
+			prAuthor:               "unauthorized-user",
+			queryAuthor:            "authorized-user",
+			prMilestone:            &Milestone{Title: githubql.String("v2.0")},
+			queryMilestone:         "v1.0",
+			prLabels:               []string{"do-not-merge"},
+			queryLabels:            []string{"lgtm", "approved"},
+			queryForbiddenLabels:   []string{"do-not-merge"},
+			reviewDecision:         githubql.PullRequestReviewDecisionChangesRequested,
+			reviewApprovedRequired: true,
+			prContexts: []Context{
+				{Context: githubql.String("ci/test"), State: githubql.StatusStateFailure},
+				{Context: githubql.String("ci/lint"), State: githubql.StatusStateError},
+			},
+			prCheckRuns: []CheckRun{
+				{Name: githubql.String("security-scan"), Status: githubql.String(githubql.CheckStatusStateCompleted), Conclusion: githubql.String(githubql.StatusStateFailure)},
+			},
+			mergeStateStatus: "BLOCKED",
+			// 2000 (branch) + 1000 (author) + 100 (milestone) + 2 (missing labels) + 1 (forbidden label) + 100 (blocked) + 50 (review) + 3 (contexts: 2 status + 1 checkrun)
+			expectedDiff:         3256,
+			expectedDescContains: "Merging to branch release-1.0 is forbidden",
+		},
+		// All aspects with author issue
+		{
+			name:                   "Complex: Multiple issues across all areas with author taking precedence",
+			prBaseBranch:           "main",
+			queryIncludedBranches:  []string{"main", "develop"},
+			prAuthor:               "external-contributor",
+			queryAuthor:            "core-team",
+			prMilestone:            nil,
+			queryMilestone:         "v1.5",
+			prLabels:               []string{"help-wanted", "needs-rebase", "wip"},
+			queryLabels:            []string{"lgtm", "approved", "size/small"},
+			queryForbiddenLabels:   []string{"wip", "needs-rebase"},
+			reviewDecision:         githubql.PullRequestReviewDecisionApproved,
+			reviewApprovedRequired: true,
+			prContexts: []Context{
+				{Context: githubql.String("ci/build"), State: githubql.StatusStatePending},
+				{Context: githubql.String("ci/unit-tests"), State: githubql.StatusStateSuccess},
+			},
+			prCheckRuns: []CheckRun{
+				{Name: githubql.String("integration-tests"), Status: githubql.String(githubql.CheckStatusStateCompleted), Conclusion: githubql.String(githubql.StatusStateSuccess)},
+				{Name: githubql.String("e2e-tests"), Status: githubql.String(githubql.CheckStatusStateCompleted), Conclusion: githubql.String(githubql.StatusStateFailure)},
+			},
+			mergeStateStatus: "CLEAN",
+			// 0 (branch OK) + 1000 (author) + 100 (milestone) + 3 (missing labels) + 2 (forbidden labels) + 0 (not blocked) + 2 (failed contexts: ci/build pending + e2e-tests failed)
+			expectedDiff:         1107,
+			expectedDescContains: "Must be by author core-team",
+		},
+		// All aspects with milestone issue
+		{
+			name:                   "Complex: Branch and author OK, other issues present with milestone precedence",
+			prBaseBranch:           "develop",
+			queryIncludedBranches:  []string{"main", "develop"},
+			prAuthor:               "approved-dev",
+			queryAuthor:            "approved-dev",
+			prMilestone:            &Milestone{Title: githubql.String("backlog")},
+			queryMilestone:         "sprint-23",
+			prLabels:               []string{"lgtm", "hold"},
+			queryLabels:            []string{"lgtm", "approved"},
+			queryForbiddenLabels:   []string{"hold", "blocked"},
+			reviewDecision:         githubql.PullRequestReviewDecisionChangesRequested,
+			reviewApprovedRequired: false,
+			prContexts: []Context{
+				{Context: githubql.String("ci/verify"), State: githubql.StatusStateSuccess},
+			},
+			prCheckRuns: []CheckRun{
+				{Name: githubql.String("code-coverage"), Status: githubql.String(githubql.CheckStatusStateCompleted), Conclusion: githubql.String(githubql.StatusStateFailure)},
+			},
+			mergeStateStatus: "BLOCKED",
+			// 0 (branch OK) + 0 (author OK) + 100 (milestone) + 1 (missing approved label) + 1 (forbidden hold label) + 100 (blocked) + 1 (failed checkrun)
+			expectedDiff:         203,
+			expectedDescContains: "Must be in milestone sprint-23",
+		},
+		//  All aspects with changes requested issue
+		{
+			name:                   "Complex: All requirements met but changes requested blocks merge",
+			prBaseBranch:           "main",
+			queryIncludedBranches:  []string{"main", "develop"},
+			prAuthor:               "core-maintainer",
+			queryAuthor:            "core-maintainer",
+			prMilestone:            &Milestone{Title: githubql.String("v2.0")},
+			queryMilestone:         "v2.0",
+			prLabels:               []string{"lgtm", "approved", "ready-to-merge"},
+			queryLabels:            []string{"lgtm", "approved"},
+			queryForbiddenLabels:   []string{"do-not-merge", "hold"},
+			reviewDecision:         githubql.PullRequestReviewDecisionChangesRequested,
+			reviewApprovedRequired: false,
+			prContexts: []Context{
+				{Context: githubql.String("ci/test"), State: githubql.StatusStateSuccess},
+				{Context: githubql.String("ci/lint"), State: githubql.StatusStateSuccess},
+				{Context: githubql.String("ci/build"), State: githubql.StatusStateSuccess},
+			},
+			prCheckRuns: []CheckRun{
+				{Name: githubql.String("security-scan"), Status: githubql.String(githubql.CheckStatusStateCompleted), Conclusion: githubql.String(githubql.StatusStateSuccess)},
+				{Name: githubql.String("e2e-tests"), Status: githubql.String(githubql.CheckStatusStateCompleted), Conclusion: githubql.String(githubql.StatusStateSuccess)},
+			},
+			mergeStateStatus: "BLOCKED",
+			// 0 (branch OK) + 0 (author OK) + 0 (milestone OK) + 0 (labels OK) + 100 (blocked) + 50(review) + 0 (contexts OK)
+			expectedDiff:         100,
+			expectedDescContains: "Blocked by GitHub (branch rulesets or protection)",
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			pr := &PullRequest{
+				ReviewDecision:   tc.reviewDecision,
+				MergeStateStatus: githubql.String(tc.mergeStateStatus),
+				HeadRefOID:       githubql.String("abc123"),
+			}
+
+			if tc.prBaseBranch != "" {
+				pr.BaseRef = struct {
+					Name   githubql.String
+					Prefix githubql.String
+				}{
+					Name: githubql.String(tc.prBaseBranch),
+				}
+			}
+
+			if tc.prAuthor != "" {
+				pr.Author = struct {
+					Login githubql.String
+				}{
+					Login: githubql.String(tc.prAuthor),
+				}
+			}
+
+			pr.Milestone = tc.prMilestone
+
+			for _, label := range tc.prLabels {
+				pr.Labels.Nodes = append(pr.Labels.Nodes, struct{ Name githubql.String }{Name: githubql.String(label)})
+			}
+
+			if len(tc.prContexts) > 0 || len(tc.prCheckRuns) > 0 {
+				var checkRunNodes []CheckRunNode
+				for _, cr := range tc.prCheckRuns {
+					checkRunNodes = append(checkRunNodes, CheckRunNode{CheckRun: cr})
+				}
+				pr.Commits.Nodes = append(pr.Commits.Nodes, struct{ Commit Commit }{
+					Commit: Commit{
+						OID: githubql.String("abc123"),
+						Status: struct{ Contexts []Context }{
+							Contexts: tc.prContexts,
+						},
+						StatusCheckRollup: StatusCheckRollup{
+							Contexts: StatusCheckRollupContext{
+								Nodes: checkRunNodes,
+							},
+						},
+					},
+				})
+			}
+
+			query := &config.TideQuery{
+				Labels:                 tc.queryLabels,
+				MissingLabels:          tc.queryForbiddenLabels,
+				Author:                 tc.queryAuthor,
+				Milestone:              tc.queryMilestone,
+				ExcludedBranches:       tc.queryExcludedBranches,
+				IncludedBranches:       tc.queryIncludedBranches,
+				ReviewApprovedRequired: tc.reviewApprovedRequired,
+			}
+
+			cc := &config.TideContextPolicy{}
+
+			desc, diff := requirementDiff(pr, query, cc)
+
+			if diff != tc.expectedDiff {
+				t.Errorf("Expected diff %d, but got %d", tc.expectedDiff, diff)
+			}
+
+			if tc.expectedDescContains != "" {
+				if !strings.Contains(desc, tc.expectedDescContains) {
+					t.Errorf("Expected description to contain %q, but got %q", tc.expectedDescContains, desc)
+				}
+			} else if desc != "" {
+				t.Errorf("Expected empty description, but got %q", desc)
+			}
+		})
+	}
+}
+
 func TestSetStatuses(t *testing.T) {
 	statusNotInPoolEmpty := fmt.Sprintf(statusNotInPool, "")
 	testcases := []struct {