diff --git a/.github/tests/merge-gate-fixtures/changes-requested-not-approval/comments.json b/.github/tests/merge-gate-fixtures/changes-requested-not-approval/comments.json new file mode 100644 index 00000000..740b8ad2 --- /dev/null +++ b/.github/tests/merge-gate-fixtures/changes-requested-not-approval/comments.json @@ -0,0 +1,9 @@ +[ + { + "user": { + "login": "coderabbitai[bot]" + }, + "body": "\n\n\ud83d\udea5 Pre-merge checks | \u2705 5\n", + "created_at": "2026-07-11T10:00:00Z" + } +] \ No newline at end of file diff --git a/.github/tests/merge-gate-fixtures/changes-requested-not-approval/reviews.json b/.github/tests/merge-gate-fixtures/changes-requested-not-approval/reviews.json new file mode 100644 index 00000000..2f72cd61 --- /dev/null +++ b/.github/tests/merge-gate-fixtures/changes-requested-not-approval/reviews.json @@ -0,0 +1,10 @@ +[ + { + "user": { + "login": "coderabbitai[bot]" + }, + "state": "CHANGES_REQUESTED", + "commit_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "submitted_at": "2026-07-11T10:00:00Z" + } +] \ No newline at end of file diff --git a/.github/tests/merge-gate-fixtures/codex-abbreviated-head-unresolvable/comments.json b/.github/tests/merge-gate-fixtures/codex-abbreviated-head-unresolvable/comments.json new file mode 100644 index 00000000..d2a96f4d --- /dev/null +++ b/.github/tests/merge-gate-fixtures/codex-abbreviated-head-unresolvable/comments.json @@ -0,0 +1,16 @@ +[ + { + "user": { + "login": "chatgpt-codex-connector[bot]" + }, + "body": "Codex Review: Didn't find any major issues.\n\n**Reviewed commit:** `aaaaaaaaaa`", + "created_at": "2026-07-11T10:00:00Z" + }, + { + "user": { + "login": "coderabbitai[bot]" + }, + "body": "\n\n๐Ÿšฅ Pre-merge checks | โœ… 5\n", + "created_at": "2026-07-11T10:00:00Z" + } +] diff --git a/.github/tests/merge-gate-fixtures/codex-abbreviated-head-unresolvable/reviews.json b/.github/tests/merge-gate-fixtures/codex-abbreviated-head-unresolvable/reviews.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/.github/tests/merge-gate-fixtures/codex-abbreviated-head-unresolvable/reviews.json @@ -0,0 +1 @@ +[] diff --git a/.github/tests/merge-gate-fixtures/codex-clean-superseded-by-findings/comments.json b/.github/tests/merge-gate-fixtures/codex-clean-superseded-by-findings/comments.json new file mode 100644 index 00000000..07d7ec8e --- /dev/null +++ b/.github/tests/merge-gate-fixtures/codex-clean-superseded-by-findings/comments.json @@ -0,0 +1,23 @@ +[ + { + "user": { + "login": "chatgpt-codex-connector[bot]" + }, + "body": "Codex Review: Didn't find any major issues.\n\n**Reviewed commit:** aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "created_at": "2026-07-11T09:00:00Z" + }, + { + "user": { + "login": "chatgpt-codex-connector[bot]" + }, + "body": "Codex Review: found 2 issues.\n\n**Reviewed commit:** aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "created_at": "2026-07-11T11:00:00Z" + }, + { + "user": { + "login": "coderabbitai[bot]" + }, + "body": "\n\n\ud83d\udea5 Pre-merge checks | \u2705 5\n", + "created_at": "2026-07-11T10:00:00Z" + } +] \ No newline at end of file diff --git a/.github/tests/merge-gate-fixtures/codex-clean-superseded-by-findings/reviews.json b/.github/tests/merge-gate-fixtures/codex-clean-superseded-by-findings/reviews.json new file mode 100644 index 00000000..0637a088 --- /dev/null +++ b/.github/tests/merge-gate-fixtures/codex-clean-superseded-by-findings/reviews.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/.github/tests/merge-gate-fixtures/codex-clean-superseded-by-review-findings/comments.json b/.github/tests/merge-gate-fixtures/codex-clean-superseded-by-review-findings/comments.json new file mode 100644 index 00000000..d2a96f4d --- /dev/null +++ b/.github/tests/merge-gate-fixtures/codex-clean-superseded-by-review-findings/comments.json @@ -0,0 +1,16 @@ +[ + { + "user": { + "login": "chatgpt-codex-connector[bot]" + }, + "body": "Codex Review: Didn't find any major issues.\n\n**Reviewed commit:** `aaaaaaaaaa`", + "created_at": "2026-07-11T10:00:00Z" + }, + { + "user": { + "login": "coderabbitai[bot]" + }, + "body": "\n\n๐Ÿšฅ Pre-merge checks | โœ… 5\n", + "created_at": "2026-07-11T10:00:00Z" + } +] diff --git a/.github/tests/merge-gate-fixtures/codex-clean-superseded-by-review-findings/reviews.json b/.github/tests/merge-gate-fixtures/codex-clean-superseded-by-review-findings/reviews.json new file mode 100644 index 00000000..fb24abec --- /dev/null +++ b/.github/tests/merge-gate-fixtures/codex-clean-superseded-by-review-findings/reviews.json @@ -0,0 +1,11 @@ +[ + { + "user": { + "login": "chatgpt-codex-connector[bot]" + }, + "state": "COMMENTED", + "commit_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "submitted_at": "2026-07-11T11:00:00Z", + "body": "Codex Review: automated suggestions were posted." + } +] diff --git a/.github/tests/merge-gate-fixtures/codex-comment-edited-after-clean/comments.json b/.github/tests/merge-gate-fixtures/codex-comment-edited-after-clean/comments.json new file mode 100644 index 00000000..3e92fa1a --- /dev/null +++ b/.github/tests/merge-gate-fixtures/codex-comment-edited-after-clean/comments.json @@ -0,0 +1,25 @@ +[ + { + "user": { + "login": "chatgpt-codex-connector[bot]" + }, + "body": "Codex Review: Found a blocking issue.\n\n**Reviewed commit:** `aaaaaaaaaa`", + "created_at": "2026-07-11T09:30:00Z", + "updated_at": "2026-07-11T11:00:00Z" + }, + { + "user": { + "login": "chatgpt-codex-connector[bot]" + }, + "body": "Codex Review: Didn't find any major issues.\n\n**Reviewed commit:** `aaaaaaaaaa`", + "created_at": "2026-07-11T10:30:00Z", + "updated_at": "2026-07-11T10:30:00Z" + }, + { + "user": { + "login": "coderabbitai[bot]" + }, + "body": "\n\n๐Ÿšฅ Pre-merge checks | โœ… 5\n", + "created_at": "2026-07-11T10:00:00Z" + } +] diff --git a/.github/tests/merge-gate-fixtures/codex-comment-edited-after-clean/reviews.json b/.github/tests/merge-gate-fixtures/codex-comment-edited-after-clean/reviews.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/.github/tests/merge-gate-fixtures/codex-comment-edited-after-clean/reviews.json @@ -0,0 +1 @@ +[] diff --git a/.github/tests/merge-gate-fixtures/codex-findings-at-head/comments.json b/.github/tests/merge-gate-fixtures/codex-findings-at-head/comments.json new file mode 100644 index 00000000..11129864 --- /dev/null +++ b/.github/tests/merge-gate-fixtures/codex-findings-at-head/comments.json @@ -0,0 +1,16 @@ +[ + { + "user": { + "login": "chatgpt-codex-connector[bot]" + }, + "body": "Codex Review: found 2 issues.\n\n**Reviewed commit:** aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "created_at": "2026-07-11T10:00:00Z" + }, + { + "user": { + "login": "coderabbitai[bot]" + }, + "body": "\n\n\ud83d\udea5 Pre-merge checks | \u2705 5\n", + "created_at": "2026-07-11T10:00:00Z" + } +] \ No newline at end of file diff --git a/.github/tests/merge-gate-fixtures/codex-findings-at-head/reviews.json b/.github/tests/merge-gate-fixtures/codex-findings-at-head/reviews.json new file mode 100644 index 00000000..0637a088 --- /dev/null +++ b/.github/tests/merge-gate-fixtures/codex-findings-at-head/reviews.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/.github/tests/merge-gate-fixtures/codex-newest-malformed-supersedes-clean/comments.json b/.github/tests/merge-gate-fixtures/codex-newest-malformed-supersedes-clean/comments.json new file mode 100644 index 00000000..bc3572e2 --- /dev/null +++ b/.github/tests/merge-gate-fixtures/codex-newest-malformed-supersedes-clean/comments.json @@ -0,0 +1,23 @@ +[ + { + "user": { + "login": "chatgpt-codex-connector[bot]" + }, + "body": "Codex Review: Didn't find any major issues.\n\n**Reviewed commit:** `aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa`", + "created_at": "2026-07-11T10:00:00Z" + }, + { + "user": { + "login": "chatgpt-codex-connector[bot]" + }, + "body": "Codex Review: result could not be parsed", + "created_at": "2026-07-11T11:00:00Z" + }, + { + "user": { + "login": "coderabbitai[bot]" + }, + "body": "\n\n๐Ÿšฅ Pre-merge checks | โœ… 5\n", + "created_at": "2026-07-11T10:00:00Z" + } +] diff --git a/.github/tests/merge-gate-fixtures/codex-newest-malformed-supersedes-clean/reviews.json b/.github/tests/merge-gate-fixtures/codex-newest-malformed-supersedes-clean/reviews.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/.github/tests/merge-gate-fixtures/codex-newest-malformed-supersedes-clean/reviews.json @@ -0,0 +1 @@ +[] diff --git a/.github/tests/merge-gate-fixtures/codex-review-findings-superseded-by-clean/comments.json b/.github/tests/merge-gate-fixtures/codex-review-findings-superseded-by-clean/comments.json new file mode 100644 index 00000000..6f09fbe9 --- /dev/null +++ b/.github/tests/merge-gate-fixtures/codex-review-findings-superseded-by-clean/comments.json @@ -0,0 +1,16 @@ +[ + { + "user": { + "login": "chatgpt-codex-connector[bot]" + }, + "body": "Codex Review: Didn't find any major issues.\n\n**Reviewed commit:** `aaaaaaaaaa`", + "created_at": "2026-07-11T11:00:00Z" + }, + { + "user": { + "login": "coderabbitai[bot]" + }, + "body": "\n\n๐Ÿšฅ Pre-merge checks | โœ… 5\n", + "created_at": "2026-07-11T10:00:00Z" + } +] diff --git a/.github/tests/merge-gate-fixtures/codex-review-findings-superseded-by-clean/reviews.json b/.github/tests/merge-gate-fixtures/codex-review-findings-superseded-by-clean/reviews.json new file mode 100644 index 00000000..65510e02 --- /dev/null +++ b/.github/tests/merge-gate-fixtures/codex-review-findings-superseded-by-clean/reviews.json @@ -0,0 +1,11 @@ +[ + { + "user": { + "login": "chatgpt-codex-connector[bot]" + }, + "state": "COMMENTED", + "commit_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "submitted_at": "2026-07-11T10:00:00Z", + "body": "Codex Review: automated suggestions were posted." + } +] diff --git a/.github/tests/merge-gate-fixtures/cr-approval-dismissed-at-head/comments.json b/.github/tests/merge-gate-fixtures/cr-approval-dismissed-at-head/comments.json new file mode 100644 index 00000000..f330751d --- /dev/null +++ b/.github/tests/merge-gate-fixtures/cr-approval-dismissed-at-head/comments.json @@ -0,0 +1,3 @@ +[ + {"user":{"login":"coderabbitai[bot]"},"body":"\n\n๐Ÿšฅ Pre-merge checks | โœ… 5\n","created_at":"2026-07-11T10:00:00Z"} +] diff --git a/.github/tests/merge-gate-fixtures/cr-approval-dismissed-at-head/reviews.json b/.github/tests/merge-gate-fixtures/cr-approval-dismissed-at-head/reviews.json new file mode 100644 index 00000000..ef88bee1 --- /dev/null +++ b/.github/tests/merge-gate-fixtures/cr-approval-dismissed-at-head/reviews.json @@ -0,0 +1,3 @@ +[ + {"user":{"login":"coderabbitai[bot]"},"state":"DISMISSED","commit_id":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","submitted_at":"2026-07-11T10:00:00Z"} +] diff --git a/.github/tests/merge-gate-fixtures/cr-approval-superseded-at-head/comments.json b/.github/tests/merge-gate-fixtures/cr-approval-superseded-at-head/comments.json new file mode 100644 index 00000000..f330751d --- /dev/null +++ b/.github/tests/merge-gate-fixtures/cr-approval-superseded-at-head/comments.json @@ -0,0 +1,3 @@ +[ + {"user":{"login":"coderabbitai[bot]"},"body":"\n\n๐Ÿšฅ Pre-merge checks | โœ… 5\n","created_at":"2026-07-11T10:00:00Z"} +] diff --git a/.github/tests/merge-gate-fixtures/cr-approval-superseded-at-head/reviews.json b/.github/tests/merge-gate-fixtures/cr-approval-superseded-at-head/reviews.json new file mode 100644 index 00000000..e4ddf943 --- /dev/null +++ b/.github/tests/merge-gate-fixtures/cr-approval-superseded-at-head/reviews.json @@ -0,0 +1,4 @@ +[ + {"user":{"login":"coderabbitai[bot]"},"state":"APPROVED","commit_id":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","submitted_at":"2026-07-11T10:00:00Z"}, + {"user":{"login":"coderabbitai[bot]"},"state":"CHANGES_REQUESTED","commit_id":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","submitted_at":"2026-07-11T11:00:00Z"} +] diff --git a/.github/tests/merge-gate-fixtures/cr-approval-then-commented-clean/comments.json b/.github/tests/merge-gate-fixtures/cr-approval-then-commented-clean/comments.json new file mode 100644 index 00000000..740b8ad2 --- /dev/null +++ b/.github/tests/merge-gate-fixtures/cr-approval-then-commented-clean/comments.json @@ -0,0 +1,9 @@ +[ + { + "user": { + "login": "coderabbitai[bot]" + }, + "body": "\n\n\ud83d\udea5 Pre-merge checks | \u2705 5\n", + "created_at": "2026-07-11T10:00:00Z" + } +] \ No newline at end of file diff --git a/.github/tests/merge-gate-fixtures/cr-approval-then-commented-clean/reviews.json b/.github/tests/merge-gate-fixtures/cr-approval-then-commented-clean/reviews.json new file mode 100644 index 00000000..91486fe0 --- /dev/null +++ b/.github/tests/merge-gate-fixtures/cr-approval-then-commented-clean/reviews.json @@ -0,0 +1,19 @@ +[ + { + "user": { + "login": "coderabbitai[bot]" + }, + "state": "APPROVED", + "commit_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "submitted_at": "2026-07-11T10:00:00Z" + }, + { + "user": { + "login": "coderabbitai[bot]" + }, + "state": "COMMENTED", + "commit_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "submitted_at": "2026-07-11T11:00:00Z", + "body": "**Actionable comments posted: 0**\n\n
\n๐Ÿ”‡ Additional comments (3)\ninformational only\n
" + } +] diff --git a/.github/tests/merge-gate-fixtures/cr-approval-then-commented-findings/comments.json b/.github/tests/merge-gate-fixtures/cr-approval-then-commented-findings/comments.json new file mode 100644 index 00000000..740b8ad2 --- /dev/null +++ b/.github/tests/merge-gate-fixtures/cr-approval-then-commented-findings/comments.json @@ -0,0 +1,9 @@ +[ + { + "user": { + "login": "coderabbitai[bot]" + }, + "body": "\n\n\ud83d\udea5 Pre-merge checks | \u2705 5\n", + "created_at": "2026-07-11T10:00:00Z" + } +] \ No newline at end of file diff --git a/.github/tests/merge-gate-fixtures/cr-approval-then-commented-findings/reviews.json b/.github/tests/merge-gate-fixtures/cr-approval-then-commented-findings/reviews.json new file mode 100644 index 00000000..da6d4b22 --- /dev/null +++ b/.github/tests/merge-gate-fixtures/cr-approval-then-commented-findings/reviews.json @@ -0,0 +1,19 @@ +[ + { + "user": { + "login": "coderabbitai[bot]" + }, + "state": "APPROVED", + "commit_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "submitted_at": "2026-07-11T10:00:00Z" + }, + { + "user": { + "login": "coderabbitai[bot]" + }, + "state": "COMMENTED", + "commit_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "submitted_at": "2026-07-11T11:00:00Z", + "body": "**Actionable comments posted: 2**\n\n
\nโš ๏ธ Outside diff range comments (2)\nfindings here\n
" + } +] diff --git a/.github/tests/merge-gate-fixtures/cr-approval-with-codex-review-findings/comments.json b/.github/tests/merge-gate-fixtures/cr-approval-with-codex-review-findings/comments.json new file mode 100644 index 00000000..8702c327 --- /dev/null +++ b/.github/tests/merge-gate-fixtures/cr-approval-with-codex-review-findings/comments.json @@ -0,0 +1,9 @@ +[ + { + "user": { + "login": "coderabbitai[bot]" + }, + "body": "\n\n๐Ÿšฅ Pre-merge checks | โœ… 5\n", + "created_at": "2026-07-11T10:00:00Z" + } +] diff --git a/.github/tests/merge-gate-fixtures/cr-approval-with-codex-review-findings/reviews.json b/.github/tests/merge-gate-fixtures/cr-approval-with-codex-review-findings/reviews.json new file mode 100644 index 00000000..89b13c05 --- /dev/null +++ b/.github/tests/merge-gate-fixtures/cr-approval-with-codex-review-findings/reviews.json @@ -0,0 +1,19 @@ +[ + { + "user": { + "login": "coderabbitai[bot]" + }, + "state": "APPROVED", + "commit_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "submitted_at": "2026-07-11T10:00:00Z" + }, + { + "user": { + "login": "chatgpt-codex-connector[bot]" + }, + "state": "COMMENTED", + "commit_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "submitted_at": "2026-07-11T11:00:00Z", + "body": "Codex Review: automated suggestions were posted." + } +] diff --git a/.github/tests/merge-gate-fixtures/cr-commented-blank-supersedes-approval/comments.json b/.github/tests/merge-gate-fixtures/cr-commented-blank-supersedes-approval/comments.json new file mode 100644 index 00000000..740b8ad2 --- /dev/null +++ b/.github/tests/merge-gate-fixtures/cr-commented-blank-supersedes-approval/comments.json @@ -0,0 +1,9 @@ +[ + { + "user": { + "login": "coderabbitai[bot]" + }, + "body": "\n\n\ud83d\udea5 Pre-merge checks | \u2705 5\n", + "created_at": "2026-07-11T10:00:00Z" + } +] \ No newline at end of file diff --git a/.github/tests/merge-gate-fixtures/cr-commented-blank-supersedes-approval/reviews.json b/.github/tests/merge-gate-fixtures/cr-commented-blank-supersedes-approval/reviews.json new file mode 100644 index 00000000..09f6f164 --- /dev/null +++ b/.github/tests/merge-gate-fixtures/cr-commented-blank-supersedes-approval/reviews.json @@ -0,0 +1,18 @@ +[ + { + "user": { + "login": "coderabbitai[bot]" + }, + "state": "APPROVED", + "commit_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "submitted_at": "2026-07-11T10:00:00Z" + }, + { + "user": { + "login": "coderabbitai[bot]" + }, + "state": "COMMENTED", + "commit_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "submitted_at": "2026-07-11T11:00:00Z" + } +] diff --git a/.github/tests/merge-gate-fixtures/cr-commented-clean-allows-codex-fallback/comments.json b/.github/tests/merge-gate-fixtures/cr-commented-clean-allows-codex-fallback/comments.json new file mode 100644 index 00000000..30676367 --- /dev/null +++ b/.github/tests/merge-gate-fixtures/cr-commented-clean-allows-codex-fallback/comments.json @@ -0,0 +1,16 @@ +[ + { + "user": { + "login": "chatgpt-codex-connector[bot]" + }, + "body": "Codex Review: Didn't find any major issues.\n\n**Reviewed commit:** aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "created_at": "2026-07-11T10:00:00Z" + }, + { + "user": { + "login": "coderabbitai[bot]" + }, + "body": "\n\n## Pre-merge checks\n| Title check | \u2705 Passed | ok |\n| Linked Issues check | \u2705 Passed | ok |\n", + "created_at": "2026-07-11T10:00:00Z" + } +] \ No newline at end of file diff --git a/.github/tests/merge-gate-fixtures/cr-commented-clean-allows-codex-fallback/reviews.json b/.github/tests/merge-gate-fixtures/cr-commented-clean-allows-codex-fallback/reviews.json new file mode 100644 index 00000000..369c5bca --- /dev/null +++ b/.github/tests/merge-gate-fixtures/cr-commented-clean-allows-codex-fallback/reviews.json @@ -0,0 +1,11 @@ +[ + { + "user": { + "login": "coderabbitai[bot]" + }, + "state": "COMMENTED", + "commit_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "submitted_at": "2026-07-11T11:00:00Z", + "body": "**Actionable comments posted: 0**\n\n
\n๐Ÿ”‡ Additional comments (1)\ninformational only\n
" + } +] diff --git a/.github/tests/merge-gate-fixtures/cr-commented-edited-after-approval/comments.json b/.github/tests/merge-gate-fixtures/cr-commented-edited-after-approval/comments.json new file mode 100644 index 00000000..72d1bdf8 --- /dev/null +++ b/.github/tests/merge-gate-fixtures/cr-commented-edited-after-approval/comments.json @@ -0,0 +1,9 @@ +[ + { + "user": { + "login": "coderabbitai[bot]" + }, + "body": "\n\n๐Ÿšฅ Pre-merge checks | โœ… 5\n", + "created_at": "2026-07-11T11:00:00Z" + } +] diff --git a/.github/tests/merge-gate-fixtures/cr-commented-edited-after-approval/reviews.json b/.github/tests/merge-gate-fixtures/cr-commented-edited-after-approval/reviews.json new file mode 100644 index 00000000..a3211b02 --- /dev/null +++ b/.github/tests/merge-gate-fixtures/cr-commented-edited-after-approval/reviews.json @@ -0,0 +1,22 @@ +[ + { + "user": { + "login": "coderabbitai[bot]" + }, + "state": "COMMENTED", + "commit_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "submitted_at": "2026-07-11T10:00:00Z", + "last_edited_at": "2026-07-11T12:00:00Z", + "body": "Actionable comments posted: 1" + }, + { + "user": { + "login": "coderabbitai[bot]" + }, + "state": "APPROVED", + "commit_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "submitted_at": "2026-07-11T11:00:00Z", + "last_edited_at": null, + "body": "" + } +] diff --git a/.github/tests/merge-gate-fixtures/cr-commented-edited-before-submit/comments.json b/.github/tests/merge-gate-fixtures/cr-commented-edited-before-submit/comments.json new file mode 100644 index 00000000..72d1bdf8 --- /dev/null +++ b/.github/tests/merge-gate-fixtures/cr-commented-edited-before-submit/comments.json @@ -0,0 +1,9 @@ +[ + { + "user": { + "login": "coderabbitai[bot]" + }, + "body": "\n\n๐Ÿšฅ Pre-merge checks | โœ… 5\n", + "created_at": "2026-07-11T11:00:00Z" + } +] diff --git a/.github/tests/merge-gate-fixtures/cr-commented-edited-before-submit/reviews.json b/.github/tests/merge-gate-fixtures/cr-commented-edited-before-submit/reviews.json new file mode 100644 index 00000000..5fc9e6b2 --- /dev/null +++ b/.github/tests/merge-gate-fixtures/cr-commented-edited-before-submit/reviews.json @@ -0,0 +1,22 @@ +[ + { + "user": { + "login": "coderabbitai[bot]" + }, + "state": "APPROVED", + "commit_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "submitted_at": "2026-07-11T10:30:00Z", + "last_edited_at": null, + "body": "" + }, + { + "user": { + "login": "coderabbitai[bot]" + }, + "state": "COMMENTED", + "commit_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "submitted_at": "2026-07-11T11:00:00Z", + "last_edited_at": "2026-07-11T10:00:00Z", + "body": "Actionable comments posted: 1" + } +] diff --git a/.github/tests/merge-gate-fixtures/cr-commented-findings-blocks-codex-fallback/comments.json b/.github/tests/merge-gate-fixtures/cr-commented-findings-blocks-codex-fallback/comments.json new file mode 100644 index 00000000..30676367 --- /dev/null +++ b/.github/tests/merge-gate-fixtures/cr-commented-findings-blocks-codex-fallback/comments.json @@ -0,0 +1,16 @@ +[ + { + "user": { + "login": "chatgpt-codex-connector[bot]" + }, + "body": "Codex Review: Didn't find any major issues.\n\n**Reviewed commit:** aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "created_at": "2026-07-11T10:00:00Z" + }, + { + "user": { + "login": "coderabbitai[bot]" + }, + "body": "\n\n## Pre-merge checks\n| Title check | \u2705 Passed | ok |\n| Linked Issues check | \u2705 Passed | ok |\n", + "created_at": "2026-07-11T10:00:00Z" + } +] \ No newline at end of file diff --git a/.github/tests/merge-gate-fixtures/cr-commented-findings-blocks-codex-fallback/reviews.json b/.github/tests/merge-gate-fixtures/cr-commented-findings-blocks-codex-fallback/reviews.json new file mode 100644 index 00000000..cd7585df --- /dev/null +++ b/.github/tests/merge-gate-fixtures/cr-commented-findings-blocks-codex-fallback/reviews.json @@ -0,0 +1,11 @@ +[ + { + "user": { + "login": "coderabbitai[bot]" + }, + "state": "COMMENTED", + "commit_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "submitted_at": "2026-07-11T11:00:00Z", + "body": "**Actionable comments posted: 2**\n\n
\nโš ๏ธ Outside diff range comments (2)\nfindings here\n
" + } +] diff --git a/.github/tests/merge-gate-fixtures/cr-commented-findings-then-approval/comments.json b/.github/tests/merge-gate-fixtures/cr-commented-findings-then-approval/comments.json new file mode 100644 index 00000000..72d1bdf8 --- /dev/null +++ b/.github/tests/merge-gate-fixtures/cr-commented-findings-then-approval/comments.json @@ -0,0 +1,9 @@ +[ + { + "user": { + "login": "coderabbitai[bot]" + }, + "body": "\n\n๐Ÿšฅ Pre-merge checks | โœ… 5\n", + "created_at": "2026-07-11T11:00:00Z" + } +] diff --git a/.github/tests/merge-gate-fixtures/cr-commented-findings-then-approval/reviews.json b/.github/tests/merge-gate-fixtures/cr-commented-findings-then-approval/reviews.json new file mode 100644 index 00000000..952f69c3 --- /dev/null +++ b/.github/tests/merge-gate-fixtures/cr-commented-findings-then-approval/reviews.json @@ -0,0 +1,20 @@ +[ + { + "user": { + "login": "coderabbitai[bot]" + }, + "state": "COMMENTED", + "commit_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "submitted_at": "2026-07-11T10:00:00Z", + "body": "Actionable comments posted: 1" + }, + { + "user": { + "login": "coderabbitai[bot]" + }, + "state": "APPROVED", + "commit_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "submitted_at": "2026-07-11T11:00:00Z", + "body": "" + } +] diff --git a/.github/tests/merge-gate-fixtures/green-codex-abbreviated-head/comments.json b/.github/tests/merge-gate-fixtures/green-codex-abbreviated-head/comments.json new file mode 100644 index 00000000..d2a96f4d --- /dev/null +++ b/.github/tests/merge-gate-fixtures/green-codex-abbreviated-head/comments.json @@ -0,0 +1,16 @@ +[ + { + "user": { + "login": "chatgpt-codex-connector[bot]" + }, + "body": "Codex Review: Didn't find any major issues.\n\n**Reviewed commit:** `aaaaaaaaaa`", + "created_at": "2026-07-11T10:00:00Z" + }, + { + "user": { + "login": "coderabbitai[bot]" + }, + "body": "\n\n๐Ÿšฅ Pre-merge checks | โœ… 5\n", + "created_at": "2026-07-11T10:00:00Z" + } +] diff --git a/.github/tests/merge-gate-fixtures/green-codex-abbreviated-head/reviews.json b/.github/tests/merge-gate-fixtures/green-codex-abbreviated-head/reviews.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/.github/tests/merge-gate-fixtures/green-codex-abbreviated-head/reviews.json @@ -0,0 +1 @@ +[] diff --git a/.github/tests/merge-gate-fixtures/green-codex-at-head-premerge-full/comments.json b/.github/tests/merge-gate-fixtures/green-codex-at-head-premerge-full/comments.json new file mode 100644 index 00000000..30676367 --- /dev/null +++ b/.github/tests/merge-gate-fixtures/green-codex-at-head-premerge-full/comments.json @@ -0,0 +1,16 @@ +[ + { + "user": { + "login": "chatgpt-codex-connector[bot]" + }, + "body": "Codex Review: Didn't find any major issues.\n\n**Reviewed commit:** aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "created_at": "2026-07-11T10:00:00Z" + }, + { + "user": { + "login": "coderabbitai[bot]" + }, + "body": "\n\n## Pre-merge checks\n| Title check | \u2705 Passed | ok |\n| Linked Issues check | \u2705 Passed | ok |\n", + "created_at": "2026-07-11T10:00:00Z" + } +] \ No newline at end of file diff --git a/.github/tests/merge-gate-fixtures/green-codex-at-head-premerge-full/reviews.json b/.github/tests/merge-gate-fixtures/green-codex-at-head-premerge-full/reviews.json new file mode 100644 index 00000000..0637a088 --- /dev/null +++ b/.github/tests/merge-gate-fixtures/green-codex-at-head-premerge-full/reviews.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/.github/tests/merge-gate-fixtures/green-cr-at-head-premerge-compact/comments.json b/.github/tests/merge-gate-fixtures/green-cr-at-head-premerge-compact/comments.json new file mode 100644 index 00000000..740b8ad2 --- /dev/null +++ b/.github/tests/merge-gate-fixtures/green-cr-at-head-premerge-compact/comments.json @@ -0,0 +1,9 @@ +[ + { + "user": { + "login": "coderabbitai[bot]" + }, + "body": "\n\n\ud83d\udea5 Pre-merge checks | \u2705 5\n", + "created_at": "2026-07-11T10:00:00Z" + } +] \ No newline at end of file diff --git a/.github/tests/merge-gate-fixtures/green-cr-at-head-premerge-compact/reviews.json b/.github/tests/merge-gate-fixtures/green-cr-at-head-premerge-compact/reviews.json new file mode 100644 index 00000000..f9c4581c --- /dev/null +++ b/.github/tests/merge-gate-fixtures/green-cr-at-head-premerge-compact/reviews.json @@ -0,0 +1,10 @@ +[ + { + "user": { + "login": "coderabbitai[bot]" + }, + "state": "APPROVED", + "commit_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "submitted_at": "2026-07-11T10:00:00Z" + } +] \ No newline at end of file diff --git a/.github/tests/merge-gate-fixtures/index.json b/.github/tests/merge-gate-fixtures/index.json new file mode 100644 index 00000000..71d5254c --- /dev/null +++ b/.github/tests/merge-gate-fixtures/index.json @@ -0,0 +1,146 @@ +[ + { + "name": "green-cr-at-head-premerge-compact", + "expect_green": true + }, + { + "name": "green-codex-at-head-premerge-full", + "expect_green": true + }, + { + "name": "green-codex-abbreviated-head", + "expect_green": true + }, + { + "name": "codex-abbreviated-head-unresolvable", + "expect_green": false + }, + { + "name": "stale-cr-approval", + "expect_green": false + }, + { + "name": "stale-codex-clean", + "expect_green": false + }, + { + "name": "codex-findings-at-head", + "expect_green": false + }, + { + "name": "codex-clean-superseded-by-findings", + "expect_green": false + }, + { + "name": "codex-clean-superseded-by-review-findings", + "expect_green": false + }, + { + "name": "codex-review-findings-superseded-by-clean", + "expect_green": false + }, + { + "name": "codex-comment-edited-after-clean", + "expect_green": false + }, + { + "name": "codex-newest-malformed-supersedes-clean", + "expect_green": false + }, + { + "name": "cr-approval-with-codex-review-findings", + "expect_green": false + }, + { + "name": "no-review-at-all", + "expect_green": false + }, + { + "name": "premerge-not-posted", + "expect_green": false + }, + { + "name": "premerge-mixed-compact", + "expect_green": false + }, + { + "name": "premerge-rate-limited-retains-green", + "expect_green": false + }, + { + "name": "premerge-failed-full", + "expect_green": false + }, + { + "name": "premerge-full-unknown-row", + "expect_green": false + }, + { + "name": "premerge-marker-missing", + "expect_green": false + }, + { + "name": "newest-summary-wins", + "expect_green": false + }, + { + "name": "newest-summary-omits-premerge", + "expect_green": false + }, + { + "name": "changes-requested-not-approval", + "expect_green": false + }, + { + "name": "cr-approval-superseded-at-head", + "expect_green": false + }, + { + "name": "cr-approval-dismissed-at-head", + "expect_green": false + }, + { + "name": "cr-approval-then-commented-findings", + "expect_green": false + }, + { + "name": "cr-approval-then-commented-clean", + "expect_green": true + }, + { + "name": "cr-commented-findings-then-approval", + "expect_green": true + }, + { + "name": "cr-commented-edited-after-approval", + "expect_green": false + }, + { + "name": "cr-commented-edited-before-submit", + "expect_green": false + }, + { + "name": "cr-commented-blank-supersedes-approval", + "expect_green": false + }, + { + "name": "cr-commented-findings-blocks-codex-fallback", + "expect_green": false + }, + { + "name": "cr-commented-clean-allows-codex-fallback", + "expect_green": true + }, + { + "name": "premerge-warning-full", + "expect_green": false + }, + { + "name": "premerge-stale-before-head", + "expect_green": false + }, + { + "name": "premerge-edited-newest-updated-wins", + "expect_green": true + } +] diff --git a/.github/tests/merge-gate-fixtures/newest-summary-omits-premerge/comments.json b/.github/tests/merge-gate-fixtures/newest-summary-omits-premerge/comments.json new file mode 100644 index 00000000..fb04d3ee --- /dev/null +++ b/.github/tests/merge-gate-fixtures/newest-summary-omits-premerge/comments.json @@ -0,0 +1,16 @@ +[ + { + "user": { + "login": "coderabbitai[bot]" + }, + "body": "\n\n๐Ÿšฅ Pre-merge checks | โœ… 5\n", + "created_at": "2026-07-11T10:00:00Z" + }, + { + "user": { + "login": "coderabbitai[bot]" + }, + "body": "\nNo pre-merge evidence was generated for this revision.", + "created_at": "2026-07-11T11:00:00Z" + } +] diff --git a/.github/tests/merge-gate-fixtures/newest-summary-omits-premerge/reviews.json b/.github/tests/merge-gate-fixtures/newest-summary-omits-premerge/reviews.json new file mode 100644 index 00000000..f47d3a38 --- /dev/null +++ b/.github/tests/merge-gate-fixtures/newest-summary-omits-premerge/reviews.json @@ -0,0 +1,10 @@ +[ + { + "user": { + "login": "coderabbitai[bot]" + }, + "state": "APPROVED", + "commit_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "submitted_at": "2026-07-11T10:00:00Z" + } +] diff --git a/.github/tests/merge-gate-fixtures/newest-summary-wins/comments.json b/.github/tests/merge-gate-fixtures/newest-summary-wins/comments.json new file mode 100644 index 00000000..3430cd72 --- /dev/null +++ b/.github/tests/merge-gate-fixtures/newest-summary-wins/comments.json @@ -0,0 +1,16 @@ +[ + { + "user": { + "login": "coderabbitai[bot]" + }, + "body": "\n\n\ud83d\udea5 Pre-merge checks | \u2705 5\n", + "created_at": "2026-07-11T09:00:00Z" + }, + { + "user": { + "login": "coderabbitai[bot]" + }, + "body": "\n\n\ud83d\udea5 Pre-merge checks | \u2705 4 | \u274c 1\n", + "created_at": "2026-07-11T11:00:00Z" + } +] \ No newline at end of file diff --git a/.github/tests/merge-gate-fixtures/newest-summary-wins/reviews.json b/.github/tests/merge-gate-fixtures/newest-summary-wins/reviews.json new file mode 100644 index 00000000..f9c4581c --- /dev/null +++ b/.github/tests/merge-gate-fixtures/newest-summary-wins/reviews.json @@ -0,0 +1,10 @@ +[ + { + "user": { + "login": "coderabbitai[bot]" + }, + "state": "APPROVED", + "commit_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "submitted_at": "2026-07-11T10:00:00Z" + } +] \ No newline at end of file diff --git a/.github/tests/merge-gate-fixtures/no-review-at-all/comments.json b/.github/tests/merge-gate-fixtures/no-review-at-all/comments.json new file mode 100644 index 00000000..740b8ad2 --- /dev/null +++ b/.github/tests/merge-gate-fixtures/no-review-at-all/comments.json @@ -0,0 +1,9 @@ +[ + { + "user": { + "login": "coderabbitai[bot]" + }, + "body": "\n\n\ud83d\udea5 Pre-merge checks | \u2705 5\n", + "created_at": "2026-07-11T10:00:00Z" + } +] \ No newline at end of file diff --git a/.github/tests/merge-gate-fixtures/no-review-at-all/reviews.json b/.github/tests/merge-gate-fixtures/no-review-at-all/reviews.json new file mode 100644 index 00000000..0637a088 --- /dev/null +++ b/.github/tests/merge-gate-fixtures/no-review-at-all/reviews.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/.github/tests/merge-gate-fixtures/premerge-edited-newest-updated-wins/comments.json b/.github/tests/merge-gate-fixtures/premerge-edited-newest-updated-wins/comments.json new file mode 100644 index 00000000..ce0341dd --- /dev/null +++ b/.github/tests/merge-gate-fixtures/premerge-edited-newest-updated-wins/comments.json @@ -0,0 +1,4 @@ +[ + {"user":{"login":"coderabbitai[bot]"},"body":"\n\n๐Ÿšฅ Pre-merge checks | โœ… 5\n","created_at":"2026-07-11T10:00:00Z","updated_at":"2026-07-11T12:00:00Z"}, + {"user":{"login":"coderabbitai[bot]"},"body":"\n\n๐Ÿšฅ Pre-merge checks | โœ… 4 | โŒ 1\n","created_at":"2026-07-11T11:00:00Z"} +] diff --git a/.github/tests/merge-gate-fixtures/premerge-edited-newest-updated-wins/reviews.json b/.github/tests/merge-gate-fixtures/premerge-edited-newest-updated-wins/reviews.json new file mode 100644 index 00000000..5b9eec05 --- /dev/null +++ b/.github/tests/merge-gate-fixtures/premerge-edited-newest-updated-wins/reviews.json @@ -0,0 +1,3 @@ +[ + {"user":{"login":"coderabbitai[bot]"},"state":"APPROVED","commit_id":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","submitted_at":"2026-07-11T10:00:00Z"} +] diff --git a/.github/tests/merge-gate-fixtures/premerge-failed-full/comments.json b/.github/tests/merge-gate-fixtures/premerge-failed-full/comments.json new file mode 100644 index 00000000..4d9cbf8a --- /dev/null +++ b/.github/tests/merge-gate-fixtures/premerge-failed-full/comments.json @@ -0,0 +1,9 @@ +[ + { + "user": { + "login": "coderabbitai[bot]" + }, + "body": "\n\n## Pre-merge checks\n### \u274c Failed checks (1)\n| Docstring Coverage | \u274c Error | low |\n", + "created_at": "2026-07-11T10:00:00Z" + } +] \ No newline at end of file diff --git a/.github/tests/merge-gate-fixtures/premerge-failed-full/reviews.json b/.github/tests/merge-gate-fixtures/premerge-failed-full/reviews.json new file mode 100644 index 00000000..f9c4581c --- /dev/null +++ b/.github/tests/merge-gate-fixtures/premerge-failed-full/reviews.json @@ -0,0 +1,10 @@ +[ + { + "user": { + "login": "coderabbitai[bot]" + }, + "state": "APPROVED", + "commit_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "submitted_at": "2026-07-11T10:00:00Z" + } +] \ No newline at end of file diff --git a/.github/tests/merge-gate-fixtures/premerge-full-unknown-row/comments.json b/.github/tests/merge-gate-fixtures/premerge-full-unknown-row/comments.json new file mode 100644 index 00000000..3492d164 --- /dev/null +++ b/.github/tests/merge-gate-fixtures/premerge-full-unknown-row/comments.json @@ -0,0 +1,16 @@ +[ + { + "user": { + "login": "chatgpt-codex-connector[bot]" + }, + "body": "Codex Review: Didn't find any major issues.\n\n**Reviewed commit:** `aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa`", + "created_at": "2026-07-11T10:00:00Z" + }, + { + "user": { + "login": "coderabbitai[bot]" + }, + "body": "\n\n## Pre-merge checks\n| Check | Status |\n| --- | --- |\n| Title check | โœ… Passed |\n| Linked Issues check | Pending |\n", + "created_at": "2026-07-11T10:00:00Z" + } +] diff --git a/.github/tests/merge-gate-fixtures/premerge-full-unknown-row/reviews.json b/.github/tests/merge-gate-fixtures/premerge-full-unknown-row/reviews.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/.github/tests/merge-gate-fixtures/premerge-full-unknown-row/reviews.json @@ -0,0 +1 @@ +[] diff --git a/.github/tests/merge-gate-fixtures/premerge-marker-missing/comments.json b/.github/tests/merge-gate-fixtures/premerge-marker-missing/comments.json new file mode 100644 index 00000000..f5c3d11d --- /dev/null +++ b/.github/tests/merge-gate-fixtures/premerge-marker-missing/comments.json @@ -0,0 +1,9 @@ +[ + { + "user": { + "login": "coderabbitai[bot]" + }, + "body": "\ud83d\udea5 Pre-merge checks | \u2705 5", + "created_at": "2026-07-11T10:00:00Z" + } +] \ No newline at end of file diff --git a/.github/tests/merge-gate-fixtures/premerge-marker-missing/reviews.json b/.github/tests/merge-gate-fixtures/premerge-marker-missing/reviews.json new file mode 100644 index 00000000..f9c4581c --- /dev/null +++ b/.github/tests/merge-gate-fixtures/premerge-marker-missing/reviews.json @@ -0,0 +1,10 @@ +[ + { + "user": { + "login": "coderabbitai[bot]" + }, + "state": "APPROVED", + "commit_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "submitted_at": "2026-07-11T10:00:00Z" + } +] \ No newline at end of file diff --git a/.github/tests/merge-gate-fixtures/premerge-mixed-compact/comments.json b/.github/tests/merge-gate-fixtures/premerge-mixed-compact/comments.json new file mode 100644 index 00000000..4ea4bc8e --- /dev/null +++ b/.github/tests/merge-gate-fixtures/premerge-mixed-compact/comments.json @@ -0,0 +1,9 @@ +[ + { + "user": { + "login": "coderabbitai[bot]" + }, + "body": "\n\n\ud83d\udea5 Pre-merge checks | \u2705 4 | \u274c 1\n", + "created_at": "2026-07-11T10:00:00Z" + } +] \ No newline at end of file diff --git a/.github/tests/merge-gate-fixtures/premerge-mixed-compact/reviews.json b/.github/tests/merge-gate-fixtures/premerge-mixed-compact/reviews.json new file mode 100644 index 00000000..f9c4581c --- /dev/null +++ b/.github/tests/merge-gate-fixtures/premerge-mixed-compact/reviews.json @@ -0,0 +1,10 @@ +[ + { + "user": { + "login": "coderabbitai[bot]" + }, + "state": "APPROVED", + "commit_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "submitted_at": "2026-07-11T10:00:00Z" + } +] \ No newline at end of file diff --git a/.github/tests/merge-gate-fixtures/premerge-not-posted/comments.json b/.github/tests/merge-gate-fixtures/premerge-not-posted/comments.json new file mode 100644 index 00000000..0637a088 --- /dev/null +++ b/.github/tests/merge-gate-fixtures/premerge-not-posted/comments.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/.github/tests/merge-gate-fixtures/premerge-not-posted/reviews.json b/.github/tests/merge-gate-fixtures/premerge-not-posted/reviews.json new file mode 100644 index 00000000..f9c4581c --- /dev/null +++ b/.github/tests/merge-gate-fixtures/premerge-not-posted/reviews.json @@ -0,0 +1,10 @@ +[ + { + "user": { + "login": "coderabbitai[bot]" + }, + "state": "APPROVED", + "commit_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "submitted_at": "2026-07-11T10:00:00Z" + } +] \ No newline at end of file diff --git a/.github/tests/merge-gate-fixtures/premerge-rate-limited-retains-green/comments.json b/.github/tests/merge-gate-fixtures/premerge-rate-limited-retains-green/comments.json new file mode 100644 index 00000000..26ead379 --- /dev/null +++ b/.github/tests/merge-gate-fixtures/premerge-rate-limited-retains-green/comments.json @@ -0,0 +1,17 @@ +[ + { + "user": { + "login": "chatgpt-codex-connector[bot]" + }, + "body": "Codex Review: Didn't find any major issues.\n\n**Reviewed commit:** `aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa`", + "created_at": "2026-07-11T10:00:00Z" + }, + { + "user": { + "login": "coderabbitai[bot]" + }, + "body": "\n\n## Review limit reached\n\n๐Ÿšฅ Pre-merge checks | โœ… 5\n\n", + "created_at": "2026-07-11T09:00:00Z", + "updated_at": "2026-07-11T11:00:00Z" + } +] diff --git a/.github/tests/merge-gate-fixtures/premerge-rate-limited-retains-green/reviews.json b/.github/tests/merge-gate-fixtures/premerge-rate-limited-retains-green/reviews.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/.github/tests/merge-gate-fixtures/premerge-rate-limited-retains-green/reviews.json @@ -0,0 +1 @@ +[] diff --git a/.github/tests/merge-gate-fixtures/premerge-stale-before-head/comments.json b/.github/tests/merge-gate-fixtures/premerge-stale-before-head/comments.json new file mode 100644 index 00000000..15a85740 --- /dev/null +++ b/.github/tests/merge-gate-fixtures/premerge-stale-before-head/comments.json @@ -0,0 +1,3 @@ +[ + {"user":{"login":"coderabbitai[bot]"},"body":"\n\n๐Ÿšฅ Pre-merge checks | โœ… 5\n","created_at":"2026-07-10T08:00:00Z"} +] diff --git a/.github/tests/merge-gate-fixtures/premerge-stale-before-head/reviews.json b/.github/tests/merge-gate-fixtures/premerge-stale-before-head/reviews.json new file mode 100644 index 00000000..5b9eec05 --- /dev/null +++ b/.github/tests/merge-gate-fixtures/premerge-stale-before-head/reviews.json @@ -0,0 +1,3 @@ +[ + {"user":{"login":"coderabbitai[bot]"},"state":"APPROVED","commit_id":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","submitted_at":"2026-07-11T10:00:00Z"} +] diff --git a/.github/tests/merge-gate-fixtures/premerge-warning-full/comments.json b/.github/tests/merge-gate-fixtures/premerge-warning-full/comments.json new file mode 100644 index 00000000..2e80da4c --- /dev/null +++ b/.github/tests/merge-gate-fixtures/premerge-warning-full/comments.json @@ -0,0 +1,3 @@ +[ + {"user":{"login":"coderabbitai[bot]"},"body":"\n\n## Pre-merge checks\n| Title check | โœ… Passed |\n| Docstring Coverage | โš ๏ธ Warning |\n","created_at":"2026-07-11T10:00:00Z"} +] diff --git a/.github/tests/merge-gate-fixtures/premerge-warning-full/reviews.json b/.github/tests/merge-gate-fixtures/premerge-warning-full/reviews.json new file mode 100644 index 00000000..5b9eec05 --- /dev/null +++ b/.github/tests/merge-gate-fixtures/premerge-warning-full/reviews.json @@ -0,0 +1,3 @@ +[ + {"user":{"login":"coderabbitai[bot]"},"state":"APPROVED","commit_id":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","submitted_at":"2026-07-11T10:00:00Z"} +] diff --git a/.github/tests/merge-gate-fixtures/stale-codex-clean/comments.json b/.github/tests/merge-gate-fixtures/stale-codex-clean/comments.json new file mode 100644 index 00000000..381d8ca3 --- /dev/null +++ b/.github/tests/merge-gate-fixtures/stale-codex-clean/comments.json @@ -0,0 +1,16 @@ +[ + { + "user": { + "login": "chatgpt-codex-connector[bot]" + }, + "body": "Codex Review: Didn't find any major issues.\n\n**Reviewed commit:** bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "created_at": "2026-07-11T10:00:00Z" + }, + { + "user": { + "login": "coderabbitai[bot]" + }, + "body": "\n\n\ud83d\udea5 Pre-merge checks | \u2705 5\n", + "created_at": "2026-07-11T10:00:00Z" + } +] \ No newline at end of file diff --git a/.github/tests/merge-gate-fixtures/stale-codex-clean/reviews.json b/.github/tests/merge-gate-fixtures/stale-codex-clean/reviews.json new file mode 100644 index 00000000..0637a088 --- /dev/null +++ b/.github/tests/merge-gate-fixtures/stale-codex-clean/reviews.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/.github/tests/merge-gate-fixtures/stale-cr-approval/comments.json b/.github/tests/merge-gate-fixtures/stale-cr-approval/comments.json new file mode 100644 index 00000000..740b8ad2 --- /dev/null +++ b/.github/tests/merge-gate-fixtures/stale-cr-approval/comments.json @@ -0,0 +1,9 @@ +[ + { + "user": { + "login": "coderabbitai[bot]" + }, + "body": "\n\n\ud83d\udea5 Pre-merge checks | \u2705 5\n", + "created_at": "2026-07-11T10:00:00Z" + } +] \ No newline at end of file diff --git a/.github/tests/merge-gate-fixtures/stale-cr-approval/reviews.json b/.github/tests/merge-gate-fixtures/stale-cr-approval/reviews.json new file mode 100644 index 00000000..7df9c8ef --- /dev/null +++ b/.github/tests/merge-gate-fixtures/stale-cr-approval/reviews.json @@ -0,0 +1,10 @@ +[ + { + "user": { + "login": "coderabbitai[bot]" + }, + "state": "APPROVED", + "commit_id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "submitted_at": "2026-07-11T10:00:00Z" + } +] \ No newline at end of file diff --git a/.github/tests/test-enable-auto-merge-pentad-gate.sh b/.github/tests/test-enable-auto-merge-pentad-gate.sh new file mode 100755 index 00000000..cff1c163 --- /dev/null +++ b/.github/tests/test-enable-auto-merge-pentad-gate.sh @@ -0,0 +1,300 @@ +#!/usr/bin/env bash +# Table-driven test for the fail-closed review/pre-merge gate the privileged +# auto-merge workflow runs before approving a trusted-bot PR. Enforced mode +# leaves arming to the maintenance agent after its live pentad check +# (actions#548). Each fixture is a (reviews.json, comments.json) pair plus the +# expected verdict; the gate must treat every missing, stale, mixed, failed, +# superseded, or unparseable state as NOT green. + +set -euo pipefail + +script="${1:-.scripts/check-merge-gates.sh}" +fixtures_dir="${2:-.github/tests/merge-gate-fixtures}" +workflow="${3:-.github/workflows/enable-auto-merge.yaml}" +readme="${6:-README.md}" + +head_sha="$(printf 'a%.0s' {1..40})" +# The freshness floor supplied by the workflow: fixture summaries dated on or +# after it are fresh; earlier ones are stale. +head_seen_at="2026-07-11T09:00:00Z" +status=0 + +# The workflow must actually consume the gate: the gates step runs the script, +# and both privileged steps are conditioned on its armable output. Enforced +# runs deliberately do not auto-arm because mutable review evidence cannot be +# bound atomically to `gh pr merge`; the maintenance agent performs that final +# live pentad check. +if [[ "$(grep -c 'check-merge-gates.sh' "$workflow")" -lt 1 ]]; then + echo "::error file=$workflow::auto-merge workflow must run check-merge-gates.sh in the gates step" + status=1 +fi + +# Backward compatibility for workflow_call consumers: a called workflow's +# GITHUB_TOKEN permissions can only be downgraded by callers' grants, so the +# job must never request more than the legacy documented minimum (the +# enforced path's extra read scopes come from the gate-lookup App token). +job_permissions="$(yq -r '.jobs."auto-merge".permissions | keys | sort | join(",")' "$workflow")" +if [[ "$job_permissions" != "contents,pull-requests" ]]; then + echo "::error file=$workflow::auto-merge job permissions must stay at the legacy caller minimum (contents, pull-requests); got: $job_permissions" + status=1 +fi + +gate_contents_permission="$(yq -r ' + [.jobs."auto-merge".steps[] + | select(.id == "gate-token") + | .with."permission-contents" // ""] + | join("\n")' "$workflow")" +if [[ "$gate_contents_permission" != "read" ]]; then + echo "::error file=$workflow::the enforced gate token needs Contents: read to resolve abbreviated Codex commit IDs uniquely" + status=1 +fi + +armable_conditions="$(yq -r ' + [.jobs."auto-merge".steps[] + | select(.name == "โœ… Approve PR" or .name == "๐Ÿ”€ Enable Auto-Merge") + | .if // ""] + | join("\n")' "$workflow")" +if [[ "$(grep -c "steps.gates.outputs.armable == 'true'" <<<"$armable_conditions")" -ne 2 ]]; then + echo "::error file=$workflow::Approve and Enable Auto-Merge steps must both be gated on steps.gates.outputs.armable" + status=1 +fi + +# The disarm step must fail CLOSED when the gate step itself failed (a lookup +# error), not silently inherit success() and leave a stale arming in place: it +# needs a status-check override plus an explicit outcome-failure branch. +disarm_condition="$(yq -r ' + [.jobs."auto-merge".steps[] + | select(.name == "๐Ÿ”’ Disarm auto-merge on failed gates") + | .if // ""] + | join("\n")' "$workflow")" +if [[ "$disarm_condition" != *"!cancelled()"* || + "$disarm_condition" != *"steps.gates.outcome == 'failure'"* || + "$disarm_condition" != *"steps.gate-token.outcome == 'failure'"* ]]; then + echo "::error file=$workflow::the disarm step must run on gate-step AND gate-token-mint failure (!cancelled() + both outcome == 'failure' branches), not only on armable == 'false'" + status=1 +fi + +# Deleted reviewer evidence (a removed pre-merge summary or Codex clean pass) +# must re-trigger the gate so stale legacy arming is actively revoked. +issue_comment_types="$(yq -r '.on.issue_comment.types | join(",")' "$workflow")" +if [[ "$issue_comment_types" != *"deleted"* ]]; then + echo "::error file=$workflow::issue_comment trigger must include the deleted type (evidence deletion is a disarm path); got: $issue_comment_types" + status=1 +fi + +pull_request_review_types="$(yq -r '.on.pull_request_review.types | join(",")' "$workflow")" +if [[ "$pull_request_review_types" != *"edited"* ]]; then + echo "::error file=$workflow::pull_request_review trigger must include the edited type so changed reviewer evidence re-evaluates the gate; got: $pull_request_review_types" + status=1 +fi + +if ! grep -q 'lastEditedAt' "$workflow" || ! grep -q 'last_edited_at' "$script"; then + echo "::error file=$workflow::review snapshots must preserve GraphQL lastEditedAt so edited CodeRabbit bodies are ordered by their durable edit time" + status=1 +fi +if ! grep -q 'author{login __typename}' "$workflow" || + ! grep -q 'author.__typename == "Bot"' "$workflow"; then + echo "::error file=$workflow::GraphQL Bot logins must be normalized to the REST-style [bot] names consumed by the gate parser" + status=1 +fi + +# Reusable workflows cannot schedule their callers. The caller-facing Usage +# block must therefore carry the same edited-review disarm trigger as the +# workflow itself; consumers copy this example when opting into enforcement. +readme_auto_merge="$(awk ' + /^### .*Enable Auto-Merge/ { in_section = 1 } + in_section { print } + in_section && /^### .*Publish App/ { exit } +' "$readme")" +documented_review_types="$(grep -A1 'pull_request_review:' <<<"$readme_auto_merge" | tail -n 1)" +if [[ "$documented_review_types" != *"edited"* ]]; then + echo "::error file=$readme::the Enable Auto-Merge caller example must include pull_request_review: edited; got: $documented_review_types" + status=1 +fi + +# Codex findings are submitted as pull-request reviews, not issue comments. +# The job must therefore re-evaluate for both supported reviewer bots; parsing +# Codex review objects is ineffective if their submitted event never runs it. +job_condition="$(yq -r '.jobs."auto-merge".if // ""' "$workflow")" +if [[ "$(grep -oF 'chatgpt-codex-connector[bot]' <<<"$job_condition" | wc -l | tr -d ' ')" -lt 2 ]]; then + echo "::error file=$workflow::pull_request_review runs must include chatgpt-codex-connector[bot] so findings actively disarm" + status=1 +fi + +# A branch returning to an earlier SHA re-uses that SHA's original check +# suites, so the freshness floor must also consider the newest force-push +# time โ€” otherwise a summary written for an intervening head passes as fresh. +# The floor lives in the shared compute-head-seen-floor.sh, and the workflow +# must consume it in the gate step. +floor_script="${4:-.scripts/compute-head-seen-floor.sh}" +if ! grep -q 'head_ref_force_pushed' "$floor_script"; then + echo "::error file=$floor_script::the head-seen freshness floor must be raised by the newest head_ref_force_pushed timeline event" + status=1 +fi +if [[ "$(grep -c 'compute-head-seen-floor.sh' "$workflow")" -lt 1 ]]; then + echo "::error file=$workflow::the workflow must compute the freshness floor via compute-head-seen-floor.sh in the gate step" + status=1 +fi + +# Commit metadata is never a safe floor: pushing a previously-created commit +# carries an old committer date, and a summary from the PREVIOUS head can +# postdate it. The floor script must fail closed instead of falling back. +if grep -q -- '--jq .commit.committer.date' "$floor_script"; then + echo "::error file=$floor_script::the freshness floor must never fall back to the commit committer date (fail closed instead)" + status=1 +fi + +# Revocation must cover BOTH arming shapes (autoMergeRequest + merge-queue +# entry) and fire from all three safety points: the red-gate disarm step, +# before an enforced approval (approval itself may satisfy a stale arming), +# and the enforced green handoff after approval. +disarm_script="${5:-.scripts/disarm-auto-merge.sh}" +if ! grep -q 'dequeuePullRequest' "$disarm_script"; then + echo "::error file=$disarm_script::disarm must dequeue merge-queue entries (dequeuePullRequest), not only --disable-auto" + status=1 +fi +if [[ "$(grep -c 'disarm-auto-merge.sh' "$workflow")" -lt 3 ]]; then + echo "::error file=$workflow::the workflow must revoke in the red-gate step, before enforced approval, and in the enforced green handoff" + status=1 +fi + +# Mutable review/pre-merge evidence can turn red after any final snapshot and +# before `gh pr merge`; GitHub has no atomic merge primitive that binds those +# surfaces. Enforced mode must therefore take the issue's conservative +# fallback: always revoke stale arming and exit before the merge call, leaving +# arming to the maintenance agent's live pentad check. The step must still run +# after an approval failure so it can revoke a previous arming first. +pre_arm_run="$(yq -r ' + [.jobs."auto-merge".steps[] + | select(.name == "๐Ÿ”€ Enable Auto-Merge") + | .run // ""] + | join("\n")' "$workflow")" + +approve_id="$(yq -r ' + [.jobs."auto-merge".steps[] + | select(.name == "โœ… Approve PR") + | .id // ""] + | join("\n")' "$workflow")" +if [[ "$approve_id" != "approve" ]]; then + echo "::error file=$workflow::Approve PR step must expose id=approve so enforced cleanup can observe approval failure" + status=1 +fi + +approve_run="$(yq -r ' + [.jobs."auto-merge".steps[] + | select(.name == "โœ… Approve PR") + | .run // ""] + | join("\n")' "$workflow")" +approve_disarm_line="$(grep -nF 'disarm-auto-merge.sh' <<<"$approve_run" | head -1 | cut -d: -f1 || true)" +# shellcheck disable=SC2016 # Match the literal workflow shell, not this test's variables. +approve_api_line="$(grep -nF 'gh api "repos/$REPOSITORY/pulls/$PR_NUMBER/reviews"' <<<"$approve_run" | head -1 | cut -d: -f1 || true)" +if [[ -z "$approve_disarm_line" || -z "$approve_api_line" || "$approve_disarm_line" -ge "$approve_api_line" ]]; then + echo "::error file=$workflow::enforced approval must revoke stale auto-merge state BEFORE posting the approval" + status=1 +fi + +enable_condition="$(yq -r ' + [.jobs."auto-merge".steps[] + | select(.name == "๐Ÿ”€ Enable Auto-Merge") + | .if // ""] + | join("\n")' "$workflow")" +if [[ "$enable_condition" != *"!cancelled()"* || + "$enable_condition" != *"steps.gates.outputs.armable == 'true'"* ]]; then + echo "::error file=$workflow::Enable Auto-Merge must run after approval failure (!cancelled + armable) so enforced cleanup cannot be skipped" + status=1 +fi + +if ! grep -Fq 'APPROVE_OUTCOME' <<<"$pre_arm_run"; then + echo "::error file=$workflow::Enable Auto-Merge must check the Approve PR outcome after enforced cleanup" + status=1 +fi +if ! grep -Fq 'enforced fallback leaves auto-arming to the maintenance agent' <<<"$pre_arm_run"; then + echo "::error file=$workflow::enforced mode must document the conservative no-auto-arm fallback" + status=1 +fi +# shellcheck disable=SC2016 # Match the literal workflow shell, not this test's variables. +if ! grep -Fq 'bash .devantler-tech-actions/.scripts/disarm-auto-merge.sh "$REPOSITORY" "$PR_NUMBER"' <<<"$pre_arm_run"; then + echo "::error file=$workflow::enforced green runs must revoke any stale auto-merge request before handing off" + status=1 +fi +if grep -Fq 'check-merge-gates.sh' <<<"$pre_arm_run" || grep -Fq 'GATE_TOKEN' <<<"$pre_arm_run"; then + echo "::error file=$workflow::Enable Auto-Merge must not pretend a second mutable evidence snapshot makes auto-arming atomic" + status=1 +fi + +handoff_line="$(grep -nF 'enforced fallback leaves auto-arming to the maintenance agent' <<<"$pre_arm_run" | head -1 | cut -d: -f1 || true)" +handoff_exit_line="$(awk -v start="$handoff_line" 'NR > start && /exit 0/ {print NR; exit}' <<<"$pre_arm_run")" +# shellcheck disable=SC2016 # Match the literal workflow shell, not this test's variables. +merge_line="$(grep -nF 'gh pr merge "$PR_NUMBER" --auto' <<<"$pre_arm_run" | head -1 | cut -d: -f1 || true)" +if [[ -z "$handoff_line" || -z "$handoff_exit_line" || -z "$merge_line" || "$handoff_exit_line" -ge "$merge_line" ]]; then + echo "::error file=$workflow::enforced fallback must exit before the legacy default-off gh pr merge call" + status=1 +fi + +# The head-seen floor must ignore check suites created for some other PR that +# happened to use the same commit SHA. Reusing the oldest cross-branch suite +# makes a stale summary look newer than the PR's adoption of the head. +floor_test_dir="$(mktemp -d)" +trap 'rm -rf "$floor_test_dir"' EXIT +mkdir -p "$floor_test_dir/bin" +cat >"$floor_test_dir/bin/gh" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +case "${2:-}" in + repos/test/repo/commits/*/check-suites) + cat <<'JSON' +{"check_suites":[ + {"id":1,"created_at":"2026-07-11T08:00:00Z","pull_requests":[{"number":999}]}, + {"id":2,"created_at":"2026-07-11T10:00:00Z","pull_requests":[{"number":42}]} +]} +JSON + ;; + repos/test/repo/issues/42/timeline) + printf '[]\n' + ;; + repos/test/repo/commits/aaaaaaaaaa) + if [[ "${MOCK_UNRESOLVABLE_PREFIX:-false}" == "true" ]]; then + echo "ambiguous commit prefix" >&2 + exit 1 + fi + printf '%s\n' "$(printf 'a%.0s' {1..40})" + ;; + *) + echo "unexpected gh invocation: $*" >&2 + exit 1 + ;; +esac +EOF +chmod +x "$floor_test_dir/bin/gh" +floor_result="$(PATH="$floor_test_dir/bin:$PATH" bash "$floor_script" \ + test/repo 42 "$head_sha" '' issue_comment)" || status=1 +if [[ "$floor_result" != "2026-07-11T10:00:00Z" ]]; then + echo "::error file=$floor_script::head-seen floor must use this PR's earliest suite; got '$floor_result'" + status=1 +fi + +while IFS= read -r fixture; do + name="$(jq -r '.name' <<<"$fixture")" + expect_green="$(jq -r '.expect_green' <<<"$fixture")" + + actual_green=false + mock_unresolvable_prefix=false + if [[ "$name" == "codex-abbreviated-head-unresolvable" ]]; then + mock_unresolvable_prefix=true + fi + if REPOSITORY=test/repo MOCK_UNRESOLVABLE_PREFIX="$mock_unresolvable_prefix" \ + PATH="$floor_test_dir/bin:$PATH" bash "$script" "$head_sha" "$head_seen_at" \ + "$fixtures_dir/$name/reviews.json" \ + "$fixtures_dir/$name/comments.json" >/dev/null; then + actual_green=true + fi + + if [[ "$actual_green" != "$expect_green" ]]; then + echo "::error file=$fixtures_dir/index.json::fixture '$name' expected green=$expect_green, got $actual_green" + status=1 + else + echo "fixture '$name': green=$actual_green" + fi +done < <(jq -c '.[]' "$fixtures_dir/index.json") + +exit "$status" diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 63b363ed..b512ba19 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1771,9 +1771,18 @@ jobs: if: "${{ !startsWith(github.head_ref, 'release-please--') && !startsWith(github.event.head_commit.message, 'chore(main): release ') }}" name: "[Test] Enable Auto-Merge" uses: ./.github/workflows/enable-auto-merge.yaml + # Reasoned coverage gap (AGENTS.md both-states convention): this live + # invocation exercises the DEFAULT-OFF state only. A second invocation + # with enforce-review-gates: true would double-approve/arm the same PR + # alongside this one โ€” conflicting side effects on one surface โ€” so the + # enabled-state gate decisions are covered by the script-level fixture + # tests (test-enable-auto-merge-pentad-gate) and the wiring flips live + # per repo via ENFORCE_MERGE_GATES after validation. permissions: pull-requests: write contents: write + checks: read + actions: read secrets: APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }} @@ -1797,6 +1806,26 @@ jobs: - name: ๐Ÿงช Verify privileged author gate run: bash .github/tests/test-enable-auto-merge-author-gate.sh + test-enable-auto-merge-pentad-gate: + if: "${{ !startsWith(github.head_ref, 'release-please--') && !startsWith(github.event.head_commit.message, 'chore(main): release ') }}" + name: "[Test] Enable Auto-Merge - Pentad Gate" + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: ๐Ÿ›ก๏ธ Harden runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + + - name: ๐Ÿ“‘ Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: ๐Ÿงช Verify fail-closed review/pre-merge gate + run: bash .github/tests/test-enable-auto-merge-pentad-gate.sh + test-create-release: if: "${{ !startsWith(github.head_ref, 'release-please--') && !startsWith(github.event.head_commit.message, 'chore(main): release ') }}" name: "[Test] Create Release - Dry Run" @@ -2344,6 +2373,7 @@ jobs: - test-run-dotnet-tests-workflow - test-enable-auto-merge - test-enable-auto-merge-author-gate + - test-enable-auto-merge-pentad-gate - test-create-release - test-deploy-github-pages - test-publish-dotnet-library @@ -2424,6 +2454,7 @@ jobs: ${{ needs.test-run-dotnet-tests-workflow.result }} ${{ needs.test-enable-auto-merge.result }} ${{ needs.test-enable-auto-merge-author-gate.result }} + ${{ needs.test-enable-auto-merge-pentad-gate.result }} ${{ needs.test-create-release.result }} ${{ needs.test-deploy-github-pages.result }} ${{ needs.test-publish-dotnet-library.result }} diff --git a/.github/workflows/enable-auto-merge.yaml b/.github/workflows/enable-auto-merge.yaml index 016dea86..f43de0f2 100644 --- a/.github/workflows/enable-auto-merge.yaml +++ b/.github/workflows/enable-auto-merge.yaml @@ -6,27 +6,97 @@ on: APP_PRIVATE_KEY: required: true description: "The private key for the GitHub App" + inputs: + enforce-review-gates: + type: boolean + required: false + default: false + description: >- + Opt-in (default-off) enforcement of the fail-closed review/pre-merge + gate before approving. Enforced runs never auto-arm because mutable + reviewer evidence cannot be bound atomically to GitHub's merge call; + the maintenance agent performs the final live pentad check. Callers + that enable enforcement should also + trigger their caller workflow on pull_request_review and + issue_comment so review results that land after the pull_request + events still re-evaluate the gate (a reusable workflow cannot + schedule its callers). ### Required Workflow Triggers ### pull_request: types: [opened, synchronize, reopened, ready_for_review] merge_group: ################################## + # Review results land AFTER the pull_request events above have run, so the + # gate re-evaluates when a reviewer bot posts, edits, or deletes its result: + # any supported reviewer-bot review (CodeRabbit approval/changes-requested, + # or Codex findings โ€” every red result must be able to DISARM), a DISMISSED + # approval, a Codex result comment, and CodeRabbit's in-place pre-merge + # summary edits. `deleted` is a disarm path: evidence an enforced approval + # relied on (a pre-merge summary or Codex clean pass) can be removed + # afterwards, and the gate must re-evaluate the now-missing state and revoke + # any stale legacy arming. These triggers only + # fire where this workflow file lives; workflow_call consumers add them to + # their caller (see the input). + pull_request_review: + types: [submitted, edited, dismissed] + issue_comment: + types: [created, edited, deleted] permissions: {} jobs: auto-merge: + # Legacy documented minimum ONLY โ€” a called workflow's GITHUB_TOKEN + # permissions must be a subset of what every caller grants (they can only + # be downgraded, never elevated), so adding scopes here would fail + # validation for existing workflow_call consumers even with enforcement + # off. The enforced path's extra read scopes come from a separate App + # token minted only on enforced runs (see ๐Ÿ”‘ gate-lookup token below). permissions: pull-requests: write contents: write + # Serialize runs per PR so an older green gate run cannot interleave with + # a newer run that saw the gate turn red (the newer run's disarm always + # executes after the older run finishes). Enforced runs never auto-arm; + # their final step revokes any stale legacy arming before handing off. + concurrency: + group: enable-auto-merge-${{ github.event.pull_request.number || github.event.issue.number || github.run_id }} + cancel-in-progress: false runs-on: ubuntu-latest if: >- ${{ - github.event_name == 'pull_request' && - !github.event.pull_request.draft && - contains( - fromJSON('["dependabot[bot]","renovate[bot]","github-actions[bot]","ksail-bot[bot]","coderabbitai[bot]"]'), - github.event.pull_request.user.login + ( + github.event_name == 'pull_request' && + !github.event.pull_request.draft && + contains( + fromJSON('["dependabot[bot]","renovate[bot]","github-actions[bot]","ksail-bot[bot]","coderabbitai[bot]"]'), + github.event.pull_request.user.login + ) + ) || + ( + github.event_name == 'pull_request_review' && + contains( + fromJSON('["coderabbitai[bot]","chatgpt-codex-connector[bot]"]'), + github.event.review.user.login + ) && + !github.event.pull_request.draft && + contains( + fromJSON('["dependabot[bot]","renovate[bot]","github-actions[bot]","ksail-bot[bot]","coderabbitai[bot]"]'), + github.event.pull_request.user.login + ) + ) || + ( + github.event_name == 'issue_comment' && + github.event.issue.pull_request && + github.event.issue.state == 'open' && + contains( + fromJSON('["coderabbitai[bot]","chatgpt-codex-connector[bot]"]'), + github.event.comment.user.login + ) && + contains( + fromJSON('["dependabot[bot]","renovate[bot]","github-actions[bot]","ksail-bot[bot]","coderabbitai[bot]"]'), + github.event.issue.user.login + ) ) }} @@ -42,23 +112,259 @@ jobs: with: client-id: ${{ vars.APP_CLIENT_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} - # Least-privilege token scope: approving and enabling auto-merge on PRs. + # Least-privilege token scope: approving and enabling auto-merge on + # PRs. The gate's read-only lookups use GITHUB_TOKEN instead (see + # the job's permissions block), so no extra App permission is ever + # required of consumer installations. permission-contents: write permission-pull-requests: write + # Self-reference: check out THIS workflow's own repo at the exact commit + # it is running from (job.workflow_repository / job.workflow_sha) โ€” never + # the caller's workspace (which does not carry the gate script) and never + # a PR-controlled ref. Same idiom as the repo's other same-commit + # self-checkouts (dependency-review.yaml). + - name: ๐Ÿ“ฅ Checkout devantler-tech/actions (this workflow's commit) + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + repository: ${{ job.workflow_repository }} + ref: ${{ job.workflow_sha }} + path: .devantler-tech-actions + persist-credentials: false + + # The issue_comment shape cannot prove the PR is open and non-draft from + # its payload alone, so eligibility is re-proven against live state for + # every event shape (fail-closed: an ineligible PR is never gated on). + - name: ๐Ÿ”Ž Resolve target pull request + id: pr + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }} + REPOSITORY: ${{ github.repository }} + run: | + echo "number=$PR_NUMBER" >> "$GITHUB_OUTPUT" + eligible=$(gh pr view "$PR_NUMBER" --repo "$REPOSITORY" --json isDraft,state \ + --jq 'if (.isDraft | not) and .state == "OPEN" then "true" else "false" end') + echo "eligible=$eligible" >> "$GITHUB_OUTPUT" + if [[ "$eligible" != "true" ]]; then + echo "::notice::PR #${PR_NUMBER} is draft or not open; skipping the auto-merge gate." + fi + + # Enforced-path-only read scopes, minted as a SEPARATE App token so the + # default-off path โ€” and therefore every legacy workflow_call consumer's + # permissions block โ€” never needs them. If a consumer's App installation + # lacks Checks/Actions read, the mint silently intersects them away and + # the gate's lookups fail, which fails the gate step CLOSED (no arming), + # never open. + - name: ๐Ÿ”‘ Generate gate-lookup token (enforced runs only) + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + id: gate-token + if: >- + steps.pr.outputs.eligible == 'true' && + (inputs.enforce-review-gates || vars.ENFORCE_MERGE_GATES == 'true') + with: + client-id: ${{ vars.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + permission-checks: read + permission-actions: read + permission-contents: read + permission-pull-requests: read + + # Fail-closed pentad gate (actions#548): approval is only allowed once a + # CURRENT-HEAD green review (CodeRabbit APPROVED or Codex clean pass) AND + # a green CodeRabbit pre-merge result are both proven. Enforced mode does + # not auto-arm: those reviewer surfaces can change after any snapshot, + # and GitHub's merge API cannot bind their state atomically to the + # request. The maintenance agent performs the final live pentad check. + # Missing, stale, mixed, or unparseable state is NOT green โ€” the step + # declines approval and revokes stale arming. The maintenance agent acts + # after its own live pentad check. Never weaken this gate to warn-only. + # + # Feature-flag-first rollout: enforcement is DEFAULT-OFF (the + # enforce-review-gates input for workflow_call callers, or the + # ENFORCE_MERGE_GATES repository/organization variable for the direct + # and required-workflow paths). With enforcement off, the pre-gate + # behavior is preserved (allowlisted trusted bots are armed) so + # consumers without CodeRabbit pre-merge summaries or agent-requested + # reviews do not silently lose bot auto-merge before the flag is + # flipped per repo/org after validation. + # Read-only: enforced lookups run on the gate-lookup App token above, + # never the job's GITHUB_TOKEN โ€” legacy callers' permissions blocks + # stay valid, and the privileged App token stays scoped to + # approving/arming. Fail-closed pipelines: any lookup error aborts the + # step (armable never set โ†’ nothing arms) instead of continuing on + # empty snapshots that could erase a red verdict. + - name: ๐Ÿ›‚ Verify review and pre-merge gates + id: gates + if: steps.pr.outputs.eligible == 'true' + env: + GH_TOKEN: ${{ steps.gate-token.outputs.token || steps.app-token.outputs.token }} + PR_NUMBER: ${{ steps.pr.outputs.number }} + REPOSITORY: ${{ github.repository }} + ENFORCE: ${{ (inputs.enforce-review-gates || vars.ENFORCE_MERGE_GATES == 'true') && 'true' || 'false' }} + EVENT_NAME: ${{ github.event_name }} + run: | + set -euo pipefail + HEAD_SHA=$(gh pr view "$PR_NUMBER" --repo "$REPOSITORY" --json headRefOid --jq .headRefOid) + + # The proven head is exported so approval is bound to the reviewed + # commit and the legacy default-off arming path cannot ride a push. + echo "head_sha=$HEAD_SHA" >> "$GITHUB_OUTPUT" + echo "enforced=$ENFORCE" >> "$GITHUB_OUTPUT" + + if [[ "$ENFORCE" != "true" ]]; then + # Default-off preserves the PRE-GATE behavior exactly: only the + # pull_request events armed before this gate existed, so the new + # review/comment retrigger paths must not arm while the flag is + # off (a changes-requested review or a findings comment must + # never be what re-arms a PR). + if [[ "$EVENT_NAME" == "pull_request" ]]; then + echo "armable=true" >> "$GITHUB_OUTPUT" + echo "::notice::Review/pre-merge gate enforcement is off (default) โ€” arming PR #${PR_NUMBER} on the trusted-author allowlist alone. Enable via the enforce-review-gates input or the ENFORCE_MERGE_GATES variable." + else + echo "armable=false" >> "$GITHUB_OUTPUT" + echo "::notice::Review/pre-merge gate enforcement is off (default) โ€” ${EVENT_NAME} runs do not arm PR #${PR_NUMBER} (pre-gate behavior preserved)." + fi + exit 0 + fi + + # Freshness floor for the pre-merge summary โ€” the shared + # .scripts/compute-head-seen-floor.sh: earliest check-suite time for + # the SHA associated with THIS PR (excluding this run's own suite, + # which would poison the floor), failing closed when no such time is + # provable, and raised to the newest force-push time (a branch + # returning to an earlier SHA re-uses its original suites). Rationale + # for each part lives in the script header. + HEAD_SEEN_AT=$(bash .devantler-tech-actions/.scripts/compute-head-seen-floor.sh \ + "$REPOSITORY" "$PR_NUMBER" "$HEAD_SHA" "$GITHUB_RUN_ID" "$EVENT_NAME") + + OWNER="${REPOSITORY%%/*}" + NAME="${REPOSITORY#*/}" + # REST exposes only submitted_at, so an edited review can look older + # than an approval it actually superseded. GraphQL's lastEditedAt is + # durable across later event shapes and keeps fail-closed ordering. + # shellcheck disable=SC2016 # GraphQL $variables, not shell expansion. + gh api graphql --paginate \ + -f query='query($owner:String!,$name:String!,$number:Int!,$endCursor:String){ + repository(owner:$owner,name:$name){pullRequest(number:$number){ + reviews(first:100,after:$endCursor){ + nodes{author{login __typename} body state commit{oid} submittedAt lastEditedAt} + pageInfo{hasNextPage endCursor} + } + }} + }' \ + -f owner="$OWNER" -f name="$NAME" -F number="$PR_NUMBER" \ + | jq -s '[.[].data.repository.pullRequest.reviews.nodes[]? | { + user: {login: ( + if .author.__typename == "Bot" and + ((.author.login // "") | endswith("[bot]") | not) + then ((.author.login // "") + "[bot]") + else (.author.login // "") + end + )}, + body: (.body // ""), + state: .state, + commit_id: (.commit.oid // ""), + submitted_at: .submittedAt, + last_edited_at: .lastEditedAt + }]' > /tmp/reviews.json + gh api "repos/$REPOSITORY/issues/$PR_NUMBER/comments" --paginate | jq -s 'add // []' > /tmp/comments.json + + if bash .devantler-tech-actions/.scripts/check-merge-gates.sh "$HEAD_SHA" "$HEAD_SEEN_AT" /tmp/reviews.json /tmp/comments.json; then + echo "armable=true" >> "$GITHUB_OUTPUT" + else + echo "armable=false" >> "$GITHUB_OUTPUT" + echo "::notice::PR #${PR_NUMBER} not armed: current-head review/pre-merge gates are not green (fail-closed). The maintenance agent arms it after its live pentad check." + fi + + # A gate that turned red AFTER an earlier run armed the PR (e.g. a + # CodeRabbit changes-requested landing behind a Codex clean pass) must + # actively revoke the arming, not just decline to re-arm. A gate step + # that FAILED outright (a lookup error under set -euo) is the same + # disarm condition, not a skip: unreadable gates on an enforced run must + # fail closed, so `!cancelled()` + outcome checks override the implicit + # success() the plain outputs-based condition would inherit. Enforcement + # is re-derived from the input/var because a failed gate step may have + # died before exporting its `enforced` output (with the flag off this + # whole condition stays false โ€” pre-gate behavior never disarms). A + # FAILED gate-token mint is the same condition again: requesting a + # permission the consumer's App installation has not granted errors the + # mint step itself, so `steps.gates` never runs โ€” unreadable gates, and + # an already-armed PR must still be revoked. So is a FAILED PR lookup + # (steps.pr errors before exporting `eligible`): the PR number is then + # re-derived from the event payload. Revocation covers BOTH arming + # shapes via .scripts/disarm-auto-merge.sh โ€” the classic + # autoMergeRequest AND a merge-queue entry (on merge-queue repos + # `--auto` ENQUEUES and autoMergeRequest reads null, so a red gate must + # dequeue or the queue merges it regardless). + - name: ๐Ÿ”’ Disarm auto-merge on failed gates + if: >- + ${{ + !cancelled() && + (steps.pr.outputs.eligible == 'true' || steps.pr.outcome == 'failure') && + (inputs.enforce-review-gates || vars.ENFORCE_MERGE_GATES == 'true') && + (steps.pr.outcome == 'failure' || + steps.gate-token.outcome == 'failure' || + steps.gates.outcome == 'failure' || + (steps.gates.outcome == 'success' && steps.gates.outputs.armable == 'false')) + }} + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + PR_NUMBER: ${{ steps.pr.outputs.number || github.event.pull_request.number || github.event.issue.number }} + REPOSITORY: ${{ github.repository }} + HEAD_SHA: ${{ steps.gates.outputs.head_sha }} + run: | + # Head-bound like the approve/default-off arm steps: a failed gate + # for an older head must not revoke state that a newer head's own run + # already evaluated. + # A gate step that died before proving a head leaves HEAD_SHA empty + # โ€” then disarm WITHOUT the head binding (fail-closed: unreadable + # gates must revoke arming; a later live maintenance check decides + # whether to arm again). + if [[ -n "$HEAD_SHA" ]]; then + CURRENT_HEAD=$(gh pr view "$PR_NUMBER" --repo "$REPOSITORY" --json headRefOid --jq .headRefOid) + if [[ "$CURRENT_HEAD" != "$HEAD_SHA" ]]; then + echo "::notice::PR #${PR_NUMBER} head moved (${HEAD_SHA} -> ${CURRENT_HEAD}); skipping disarm โ€” the newer head's own gate run decides." + exit 0 + fi + else + echo "::warning::PR #${PR_NUMBER}: gate lookups failed before proving a head; disarming without head binding (fail-closed)." + fi + bash .devantler-tech-actions/.scripts/disarm-auto-merge.sh "$REPOSITORY" "$PR_NUMBER" + - name: โœ… Approve PR + id: approve + if: steps.gates.outputs.armable == 'true' env: GH_TOKEN: ${{ steps.app-token.outputs.token }} - PR_NUMBER: ${{ github.event.pull_request.number }} + PR_NUMBER: ${{ steps.pr.outputs.number }} REPOSITORY: ${{ github.repository }} + HEAD_SHA: ${{ steps.gates.outputs.head_sha }} + ENFORCED: ${{ steps.gates.outputs.enforced }} run: | + set -euo pipefail + + # Approval can itself satisfy the last protection on an auto-merge + # request left by an earlier default-off run. Revoke that stale state + # BEFORE approving so enforced mode cannot merge in the gap between + # approval and the final handoff cleanup. + if [[ "$ENFORCED" == "true" ]]; then + if ! bash .devantler-tech-actions/.scripts/disarm-auto-merge.sh "$REPOSITORY" "$PR_NUMBER"; then + echo "::error::PR #${PR_NUMBER}: stale auto-merge state could not be revoked before approval." + exit 1 + fi + fi + set +e - REVIEW_OUTPUT=$(gh pr review "$PR_NUMBER" --approve --repo "$REPOSITORY" 2>&1) + # commit_id pins the approval to the gate-proven head: a commit + # pushed after the gate ran gets no approval from this run. + REVIEW_OUTPUT=$(gh api "repos/$REPOSITORY/pulls/$PR_NUMBER/reviews" \ + -f event=APPROVE -f commit_id="$HEAD_SHA" 2>&1) REVIEW_EXIT_CODE=$? set -e if [[ $REVIEW_EXIT_CODE -eq 0 ]]; then - echo "โœ… PR #${PR_NUMBER} approved" + echo "โœ… PR #${PR_NUMBER} approved at ${HEAD_SHA}" elif [[ "$REVIEW_OUTPUT" == *"Can not approve your own pull request"* ]]; then echo "::warning::Could not approve PR #${PR_NUMBER} because GitHub does not allow self-approval. Skipping approval." else @@ -68,15 +374,53 @@ jobs: fi - name: ๐Ÿ”€ Enable Auto-Merge + if: >- + ${{ + !cancelled() && + steps.gates.outputs.armable == 'true' + }} env: GH_TOKEN: ${{ steps.app-token.outputs.token }} - PR_NUMBER: ${{ github.event.pull_request.number }} + PR_NUMBER: ${{ steps.pr.outputs.number }} REPOSITORY: ${{ github.repository }} + HEAD_SHA: ${{ steps.gates.outputs.head_sha }} + ENFORCED: ${{ steps.gates.outputs.enforced }} + APPROVE_OUTCOME: ${{ steps.approve.outcome }} run: | + set -euo pipefail + + # Enforced fallback: review and pre-merge evidence is mutable after + # every snapshot, while GitHub's merge API can bind only the head + # SHA. There is therefore no atomic auto-arm operation that proves + # the full pentad remained green. Always revoke any stale legacy + # autoMergeRequest/queue entry and leave auto-arming to the + # maintenance agent after its live pentad check (actions#548). + if [[ "$ENFORCED" == "true" ]]; then + if ! bash .devantler-tech-actions/.scripts/disarm-auto-merge.sh "$REPOSITORY" "$PR_NUMBER"; then + echo "::error::PR #${PR_NUMBER}: enforced gates were green, but fail-closed revocation of stale auto-merge state failed." + exit 1 + fi + if [[ "$APPROVE_OUTCOME" != "success" ]]; then + echo "::error::PR #${PR_NUMBER}: approval failed; stale auto-merge state was revoked." + exit 1 + fi + echo "::notice::PR #${PR_NUMBER}: enforced fallback leaves auto-arming to the maintenance agent after its live pentad check; stale auto-merge state was revoked." + exit 0 + fi + + # Preserve default-off behavior: an unexpected approval failure is + # a hard stop and must never fall through to the legacy arming path. + if [[ "$APPROVE_OUTCOME" != "success" ]]; then + echo "::error::PR #${PR_NUMBER}: approval failed; auto-merge was not enabled." + exit 1 + fi + REPO_INFO=$(gh api "repos/$REPOSITORY" --jq '.allow_auto_merge') if [[ "$REPO_INFO" != "true" ]]; then echo "::warning::Auto-merge is not enabled on this repository. Contact a repository admin to enable it in Settings > Pull Requests > Allow auto-merge." exit 0 fi - gh pr merge "$PR_NUMBER" --auto --squash --repo "$REPOSITORY" - echo "โœ… Auto-merge enabled for PR #${PR_NUMBER} using squash method" + # --match-head-commit preserves the legacy default-off behavior + # while binding its arming request to the allowlist-proven head. + gh pr merge "$PR_NUMBER" --auto --squash --repo "$REPOSITORY" --match-head-commit "$HEAD_SHA" + echo "โœ… Auto-merge enabled for PR #${PR_NUMBER} at ${HEAD_SHA} using squash method" diff --git a/.scripts/check-merge-gates.sh b/.scripts/check-merge-gates.sh new file mode 100755 index 00000000..57ca8c3e --- /dev/null +++ b/.scripts/check-merge-gates.sh @@ -0,0 +1,266 @@ +#!/usr/bin/env bash +# Fail-closed review/pre-merge gate for the privileged auto-merge workflow. +# +# Usage: check-merge-gates.sh +# head-sha the pull request's current head commit SHA +# head-seen-at ISO8601 time GitHub last saw the head BECOME the head +# (the caller passes the earliest check-suite created_at +# for the SHA โ€” raised to the newest force-push time when +# the branch later returned to an earlier SHA). This is +# the freshness floor for the pre-merge summary, which +# CodeRabbit edits in place and which carries no commit +# SHA of its own. Commit metadata alone is NOT a safe +# floor: pushing a previously-created commit object +# carries an old committer date. +# reviews-json file holding the FULL paginated GraphQL review array, +# normalized to REST-style keys plus last_edited_at +# comments-json file holding the FULL paginated `issues//comments` array +# +# Exits 0 only when BOTH gates are proven at the current head: +# 1. a green review โ€” CodeRabbit's LATEST review verdict at the head is +# APPROVED (an earlier approval superseded by CHANGES_REQUESTED or a +# dismissal is NOT green, and a COMMENTED review at the head that is not +# explicitly clean supersedes an approval and blocks the Codex +# fallback), or โ€” when CodeRabbit has no blocking verdict at the head and +# no current-head Codex findings review exists โ€” the latest Codex result +# comment is a clean pass ("Didn't find any major issues") whose +# "Reviewed commit" equals the head; +# 2. a green CodeRabbit pre-merge result โ€” the most recently UPDATED +# auto-generated summary (stable marker required; CodeRabbit edits the +# summary in place, so created_at alone selects a stale revision). That +# newest summary itself must carry an unambiguously green pre-merge +# section: a positive check-mark count and no error/inconclusive/warning +# marks in either shape, and its update time must not be older than the +# head-seen floor (a summary last touched before GitHub saw the head can +# only describe an earlier state). A newer summary with no pre-merge +# section โ€” or a rate-limit overlay that retained an older green section โ€” +# supersedes the older result and fails closed. +# When walkthrough boundary markers exist, only the bounded region is +# parsed so echoed marker text elsewhere cannot spoof it. +# +# Everything else โ€” missing, stale, mixed, unparseable, or absent state โ€” is +# NOT green and exits 1 (the workflow declines approval and revokes stale +# arming; the maintenance agent acts after its own live pentad check). Never +# weaken this to warn-only. + +set -euo pipefail + +if [[ $# -ne 4 ]]; then + echo "usage: $0 " >&2 + exit 2 +fi + +head_sha="$1" +head_seen_at="$2" +reviews_json="$3" +comments_json="$4" + +review_state="missing" +premerge_state="not-posted" + +# --- Gate 1: green review at the current head ------------------------------- + +# CodeRabbit's verdict at the head is its LATEST verdict-bearing review there: +# an APPROVED superseded by CHANGES_REQUESTED (or dismissed) must not count. +cr_latest_verdict_at_head="$(jq -r --arg sha "$head_sha" ' + def effective_at: + [.submitted_at, .last_edited_at] | map(select(. != null)) | max // ""; + [.[] + | select(.user.login == "coderabbitai[bot]") + | select(.commit_id == $sha) + | select(.state == "APPROVED" or .state == "CHANGES_REQUESTED" + or .state == "DISMISSED")] + | sort_by(effective_at) | last | .state // empty' "$reviews_json")" + +cr_approved_anywhere="$(jq -r ' + [.[] | select(.user.login == "coderabbitai[bot]" and .state == "APPROVED")] + | length' "$reviews_json")" + +if [[ "$cr_latest_verdict_at_head" == "APPROVED" ]]; then + review_state="green" +elif [[ -n "$cr_latest_verdict_at_head" ]]; then + # An explicit non-approval verdict at the head blocks arming outright โ€” a + # Codex clean pass must not override CodeRabbit's CHANGES_REQUESTED. + review_state="needs-fix" +elif [[ "$cr_approved_anywhere" -gt 0 ]]; then + review_state="stale" +fi + +# CodeRabbit posts incremental findings as a COMMENTED review WITHOUT issuing +# a verdict, so a COMMENTED review at the head that lands after the latest +# verdict (or with no verdict at all) must block a green outcome โ€” it +# supersedes an earlier approval AND pre-empts the Codex fallback below. Only +# a body that explicitly proves clean ("Actionable comments posted: 0") +# preserves the state; a blank or unparseable body counts as findings +# (fail-closed โ€” a bodyless COMMENTED review can still carry inline review +# comments). GraphQL lastEditedAt makes an edited older review supersede a +# later-submitted approval durably. The EXISTENCE marker line distinguishes +# "no such review" from "review with an empty body". +cr_commented_probe="$(jq -r --arg sha "$head_sha" ' + def effective_at: + [.submitted_at, .last_edited_at] | map(select(. != null)) | max // ""; + ([.[] + | select(.user.login == "coderabbitai[bot]") + | select(.commit_id == $sha) + | select(.state == "APPROVED" or .state == "CHANGES_REQUESTED" + or .state == "DISMISSED")] + | sort_by(effective_at) | last | effective_at) as $verdict_at + | [.[] + | select(.user.login == "coderabbitai[bot]") + | select(.commit_id == $sha) + | select(.state == "COMMENTED") + | select(effective_at >= $verdict_at)] + | sort_by(effective_at) | last + | if . == null then "absent" else "present\n" + (.body // "") end' "$reviews_json")" + +if [[ "$cr_commented_probe" == present* && + "$cr_commented_probe" != *"Actionable comments posted: 0"* ]]; then + review_state="needs-fix" +fi + +# Codex lane: clean results are ISSUE COMMENTS carrying "**Reviewed commit:** +# " (often abbreviated), while findings arrive as review objects at the +# exact head. Any current-head findings review is red evidence even when a +# later clean comment exists. This conservative result can only withhold the +# workflow's approval; the maintenance agent still evaluates the live pentad. +# An abbreviated clean SHA is accepted only when GitHub's commit endpoint +# resolves it uniquely to the exact head; raw prefix matching is not proof. +codex_findings_at_head="$(jq -r --arg sha "$head_sha" ' + [.[] + | select(.user.login == "chatgpt-codex-connector[bot]") + | select((.commit_id // "" | ascii_downcase) == ($sha | ascii_downcase)) + | select(.state == "COMMENTED" or .state == "CHANGES_REQUESTED")] + | length' "$reviews_json")" + +latest_codex_comment_probe="$(jq -r ' + [.[] + | select(.user.login == "chatgpt-codex-connector[bot]") + | (.body // "") as $body + | select($body | contains("Codex Review:")) + | ([try ($body + | capture("\\*\\*Reviewed commit:\\*\\*[[:space:]]*`?(?[0-9a-fA-F]{7,40})") + | .sha) catch ""] + | first // "" | ascii_downcase) as $reviewed + | {at: (.updated_at // .created_at), reviewed: $reviewed, body: $body}] + | sort_by(.at) | last + | if . == null then "absent" + else "present\n" + .reviewed + "\n" + .body + end' "$comments_json")" + +if [[ "$codex_findings_at_head" -gt 0 ]]; then + review_state="needs-fix" +elif [[ "$latest_codex_comment_probe" == present* ]]; then + latest_codex_payload="${latest_codex_comment_probe#*$'\n'}" + latest_codex_reviewed="${latest_codex_payload%%$'\n'*}" + latest_codex_body="${latest_codex_payload#*$'\n'}" + codex_comment_matches_head=false + + if [[ ${#latest_codex_reviewed} -eq 40 && "$latest_codex_reviewed" == "$head_sha" ]]; then + codex_comment_matches_head=true + elif [[ ${#latest_codex_reviewed} -lt 40 && -n "${REPOSITORY:-}" ]]; then + resolved_codex_sha="" + if resolved_codex_sha=$(gh api \ + "repos/$REPOSITORY/commits/$latest_codex_reviewed" --jq .sha 2>/dev/null) && + [[ "$resolved_codex_sha" == "$head_sha" ]]; then + codex_comment_matches_head=true + fi + fi + + if [[ "$codex_comment_matches_head" == "true" && + "$latest_codex_body" == *"Didn't find any major issues"* ]]; then + if [[ "$review_state" == "missing" || "$review_state" == "stale" ]]; then + review_state="green" + fi + elif [[ "$codex_comment_matches_head" == "true" ]]; then + review_state="needs-fix" + fi +fi + +# --- Gate 2: green CodeRabbit pre-merge result ------------------------------ + +summary_marker='' + +# CodeRabbit EDITS its auto-generated summary in place across review cycles, +# so the newest revision is the one with the greatest updated_at (falling back +# to created_at), never the newest created_at alone. One call returns the +# selected summary's touch time on the first line and its body after it. +premerge_selected="$(jq -r --arg marker "$summary_marker" ' + [.[] + | select(.user.login == "coderabbitai[bot]") + | select(.body | contains($marker))] + | sort_by(.updated_at // .created_at) | last + | if . == null then empty + else ((.updated_at // .created_at) // "") + "\n" + .body end' "$comments_json")" + +premerge_touched_at="${premerge_selected%%$'\n'*}" +premerge_body="${premerge_selected#*$'\n'}" + +if [[ -n "$premerge_body" ]]; then + # The summary carries no commit SHA, so freshness is the proxy tie to the + # head: a summary last updated before GitHub first saw the head can only + # describe an earlier state. ISO8601 Zulu timestamps compare lexically. + if [[ -n "$head_seen_at" && "$premerge_touched_at" < "$head_seen_at" ]]; then + premerge_state="stale" + elif [[ "$premerge_body" == *''* || + "$premerge_body" == *"## Review limit reached"* ]]; then + # CodeRabbit edits the existing summary when rate-limited and can retain + # the previous run's compact `โœ… 5` section underneath the warning. That + # stale display is not a current evaluation and must never become green. + premerge_state="inconclusive" + else + region="$premerge_body" + if [[ "$premerge_body" == *''* && + "$premerge_body" == *''* ]]; then + region="${premerge_body#*}" + region="${region%%*}" + fi + + compact_line="$(grep -oE '๐Ÿšฅ Pre-merge checks \|[^<]*' <<<"$region" | head -n 1 || true)" + if [[ -n "$compact_line" ]]; then + # Compact shape: green only with a positive โœ… count and no positive + # โŒ / โ“ / โš ๏ธ counter anywhere in the summary line. + if grep -qE 'โœ… [1-9][0-9]*' <<<"$compact_line" && + ! grep -qE '(โŒ|โ“|โš ๏ธ) *[1-9][0-9]*' <<<"$compact_line"; then + premerge_state="green" + else + premerge_state="failed" + fi + elif [[ "$region" == *"## Pre-merge checks"* ]]; then + # Full shape: every Markdown check row must explicitly be `โœ… Passed`. + # Header/separator rows are ignored; an unknown/pending data row is red + # even when another row passed. Requiring at least one data row keeps an + # unrecognized future shape fail-closed. + full_check_rows="$(awk ' + /^## Pre-merge checks[[:space:]]*$/ { in_section = 1; next } + in_section && /^##[[:space:]]/ { exit } + in_section && /^\|/ { + if ($0 ~ /^\|[-[:space:]:|]+\|[[:space:]]*$/) next + if ($0 ~ /\|[[:space:]]*(Status|Result)[[:space:]]*\|/) next + print + } + ' <<<"$region")" + if [[ -n "$full_check_rows" && "$region" != *"โŒ"* && + "$region" != *"โ“"* && "$region" != *"โš ๏ธ"* ]] && + ! grep -qvF 'โœ… Passed' <<<"$full_check_rows"; then + premerge_state="green" + else + premerge_state="failed" + fi + else + premerge_state="inconclusive" + fi + fi +fi + +# --- Verdict ----------------------------------------------------------------- + +echo "review=$review_state" +echo "premerge=$premerge_state" + +if [[ "$review_state" == "green" && "$premerge_state" == "green" ]]; then + echo "gates=green" + exit 0 +fi + +echo "gates=not-green (fail-closed: arming skipped)" +exit 1 diff --git a/.scripts/compute-head-seen-floor.sh b/.scripts/compute-head-seen-floor.sh new file mode 100644 index 00000000..bb98af47 --- /dev/null +++ b/.scripts/compute-head-seen-floor.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# Freshness floor for CodeRabbit's pre-merge summary: the time GitHub last saw +# the given SHA BECOME the PR head. Used by the fail-closed gate step before +# it trusts a CodeRabbit pre-merge summary. +# +# Usage: compute-head-seen-floor.sh [own-run-id] [event-name] +# repository owner/name +# pr-number the pull request number (for the force-push timeline lookup) +# head-sha the head commit SHA +# own-run-id this workflow run's id โ€” its own check suite is created by +# the triggering event itself (often AFTER the summary edit +# that triggered it), so as the only suite for a head it would +# poison the floor and wedge arming; it is excluded from part 1. +# event-name the triggering event (github.event_name) โ€” on a pull_request +# event the run's OWN suite was created by the push itself, so +# it is a safe floor when no other suite exists (part 2). +# +# Floor construction (each part raises, never lowers โ€” fail-closed): +# 1. earliest check-suite created_at for the SHA that is associated with THIS +# pull request (excluding this run's own suite) โ€” when GitHub first saw the +# commit as this PR's head; an older suite for another branch/PR must not +# lower the floor. Commit metadata alone is NOT a safe floor (pushing a +# previously-created commit object carries an old committer date, and a +# pre-existing summary from the PREVIOUS head can postdate it โ€” a +# committer-date floor would let that stale summary pass); +# 2. when no other suite exists yet: on a pull_request-event run the own +# suite's created_at IS push time and is used; on comment/review-driven +# runs there is NO provable head-seen time, so the script FAILS (exit 1) +# and the caller's gate fails closed โ€” never a committer-date fallback; +# 3. raised to the newest head_ref_force_pushed time when one exists โ€” a +# branch that RETURNS to an earlier SHA re-uses that SHA's original check +# suites, so without this a summary written for an intervening head would +# pass as fresh. + +set -euo pipefail + +if [[ $# -lt 3 || $# -gt 5 ]]; then + echo "usage: $0 [own-run-id] [event-name]" >&2 + exit 2 +fi + +repository="$1" +pr_number="$2" +head_sha="$3" +own_run_id="${4:-}" +event_name="${5:-}" + +own_suite_id="" +if [[ -n "$own_run_id" ]]; then + own_suite_id=$(gh api "repos/$repository/actions/runs/$own_run_id" --jq '.check_suite_id // empty') +fi + +suites_json=$(gh api "repos/$repository/commits/$head_sha/check-suites" --paginate | jq -s '[.[].check_suites[]?]') +floor=$(jq -r --arg own "$own_suite_id" --argjson pr "$pr_number" ' + [.[] + | select((.id | tostring) != $own) + | select(any(.pull_requests[]?; .number == $pr)) + | .created_at + | select(. != null)] + | min // empty' <<<"$suites_json") +if [[ -z "$floor" && "$event_name" == "pull_request" && -n "$own_suite_id" ]]; then + floor=$(jq -r --arg own "$own_suite_id" --argjson pr "$pr_number" ' + [.[] + | select((.id | tostring) == $own) + | select(any(.pull_requests[]?; .number == $pr)) + | .created_at + | select(. != null)] + | min // empty' <<<"$suites_json") +fi +if [[ -z "$floor" ]]; then + echo "::error::cannot prove when $head_sha became the PR head (no usable check suite on a ${event_name:-non-pull_request} run) โ€” failing closed." >&2 + exit 1 +fi + +last_force_push_at=$(gh api "repos/$repository/issues/$pr_number/timeline" --paginate | + jq -rs '[.[][] | select(.event == "head_ref_force_pushed") | .created_at | select(. != null)] | max // empty') +if [[ -n "$last_force_push_at" && "$last_force_push_at" > "$floor" ]]; then + floor="$last_force_push_at" +fi + +printf '%s\n' "$floor" diff --git a/.scripts/disarm-auto-merge.sh b/.scripts/disarm-auto-merge.sh new file mode 100644 index 00000000..7b172846 --- /dev/null +++ b/.scripts/disarm-auto-merge.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# Revoke any pending auto-merge for a pull request โ€” BOTH arming shapes: +# - a classic autoMergeRequest (disable with `gh pr merge --disable-auto`); +# - a merge-queue entry (on merge-queue repos `--auto` ENQUEUES once +# requirements are met and autoMergeRequest reads null, so a red gate +# must DEQUEUE the entry or it merges from the queue regardless). +# Shared by the red-gate disarm step and the enforced-mode approval/handoff +# cleanup in the privileged auto-merge workflow. Prints what it revoked; +# exits 0 when nothing was pending. Fail-closed callers treat any error as a +# failed disarm. +# +# Usage: disarm-auto-merge.sh + +set -euo pipefail + +if [[ $# -ne 2 ]]; then + echo "usage: $0 " >&2 + exit 2 +fi + +repository="$1" +pr_number="$2" +owner="${repository%%/*}" +name="${repository#*/}" + +# shellcheck disable=SC2016 # GraphQL $variables, not shell expansion +state="$(gh api graphql \ + -f query='query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){pullRequest(number:$number){id isInMergeQueue autoMergeRequest{enabledAt}}}}' \ + -f owner="$owner" -f name="$name" -F number="$pr_number" \ + --jq '.data.repository.pullRequest | "\(.id) \(.autoMergeRequest != null) \(.isInMergeQueue)"')" +read -r pr_id armed queued <<<"$state" + +if [[ "$armed" == "true" ]]; then + gh pr merge "$pr_number" --disable-auto --repo "$repository" + echo "::warning::Auto-merge DISARMED for PR #${pr_number}: fail-closed review-gate policy requires live maintenance-agent arming." +fi + +if [[ "$queued" == "true" ]]; then + # shellcheck disable=SC2016 # GraphQL $variables, not shell expansion + gh api graphql \ + -f query='mutation($id:ID!){dequeuePullRequest(input:{id:$id}){clientMutationId}}' \ + -f id="$pr_id" >/dev/null + echo "::warning::PR #${pr_number} DEQUEUED from the merge queue: fail-closed review-gate policy requires live maintenance-agent arming." +fi + +if [[ "$armed" != "true" && "$queued" != "true" ]]; then + echo "PR #${pr_number}: no pending auto-merge or merge-queue entry to revoke." +fi diff --git a/README.md b/README.md index 7a2f4d47..25a9616a 100644 --- a/README.md +++ b/README.md @@ -203,23 +203,49 @@ jobs:
Click to expand -[.github/workflows/enable-auto-merge.yaml](.github/workflows/enable-auto-merge.yaml) is a workflow that approves and enables auto-merge on pull requests from trusted bots and maintainers. +[.github/workflows/enable-auto-merge.yaml](.github/workflows/enable-auto-merge.yaml) approves pull requests from an exact-match allowlist of trusted single-author bots and, with enforcement off, preserves the legacy head-bound auto-merge behavior. Its **opt-in, default-off** fail-closed review/pre-merge gate (`.scripts/check-merge-gates.sh`, enabled via the `enforce-review-gates` input or the `ENFORCE_MERGE_GATES` repository/organization variable) requires, at the PR's **current head**, a green review (CodeRabbit's latest head verdict `APPROVED`, or a Codex clean pass when CodeRabbit has no blocking head result) and a green, **fresh** CodeRabbit pre-merge result before approval. Missing, stale, edited, mixed, unknown, or unreadable evidence is red and actively revokes both classic auto-merge and merge-queue state. Enforced runs deliberately **never auto-arm**: GitHub cannot atomically bind mutable reviewer evidence to its merge call, so the workflow revokes stale arming before approval, binds approval to the proven head, revokes again after approval, and leaves final arming to the portfolio maintenance agent's live pentad check. The gate script is checked out from the **workflow-defining** repository at the running commit (`job.workflow_repository`/`job.workflow_sha`); the legacy default-off arming remains bound with `--match-head-commit`. Because review results land after the `pull_request` events, the workflow also triggers on `pull_request_review`/`issue_comment` where it lives; `workflow_call` consumers that enable enforcement must add those triggers to their **caller** workflow (a reusable workflow cannot schedule its callers). #### Usage ```yaml +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + # Required when enforce-review-gates is true: review results land after + # the pull_request events, so the caller must re-invoke the gate on them + # โ€” including edits and dismissals, which can turn green evidence red and + # must be able to DISARM, and deleted comments, since removing a pre-merge + # summary or Codex clean pass is evidence deletion the gate must re-evaluate. + pull_request_review: + types: [submitted, edited, dismissed] + issue_comment: + types: [created, edited, deleted] + jobs: auto-merge: uses: devantler-tech/actions/.github/workflows/enable-auto-merge.yaml@{ref} # ref + permissions: + pull-requests: write + contents: write + with: + enforce-review-gates: false # default; flip after the repo's review lanes are validated secrets: APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }} ``` +> **Note:** The caller grants only the legacy minimum above, with or without +> enforcement โ€” the enforced gate's read-only lookups run on a separate App +> token minted only on enforced runs, so opting in requires the GitHub App +> installation (not the caller's `GITHUB_TOKEN`) to include **Checks: read**, +> **Actions: read**, and **Contents: read**. If the installation lacks them, +> the gate fails closed (approval is withheld and stale arming is revoked) rather than open. + #### Secrets and Inputs -| Key | Type | Default | Required | Description | -|-------------------|--------|---------|----------|------------------------| -| `APP_PRIVATE_KEY` | Secret | - | Yes | GitHub App private key | +| Key | Type | Default | Required | Description | +|------------------------|--------|---------|----------|-----------------------------------------------------------------------| +| `APP_PRIVATE_KEY` | Secret | - | Yes | GitHub App private key | +| `enforce-review-gates` | Input | `false` | No | Opt-in fail-closed gate before approval; agent arms after live pentad |