[object Object]

← back to Cli Printing Press

feat(ci): surface unresolved review threads as a status check

21ae8a5407c27d1984c2db7ae917d2903e56fe00 · 2026-05-16 14:34:36 -0700 · Trevin Chow

Ports the conversation-resolution-check workflow from the downstream
library repo (mvanhorn/printing-press-library#620, #621). The gate
itself is enforced by Mergify (#1536 adds the
`#review-threads-unresolved = 0` condition to .mergify.yml). This
workflow is the visibility layer.

Why both: Mergify's existing `Mergify Merge Protections` check_run
is the most readable surface for "what's missing" after a maintainer
applies `ready-to-merge`, but pre-label it just says "skipping" with
no specific signal about thread state. An author who pushed a fix
but didn't click "Resolve conversation" sees green CI, green Greptile
Review, and no obvious cue. This workflow puts an explicit
"All conversations resolved" check_run in the status list regardless
of label state.

Lifecycle coverage: pull_request_target for new commits,
pull_request_review for new reviews from Greptile, and
pull_request_review_comment for new inline findings.
concurrency.cancel-in-progress coalesces bursts. Workflow_dispatch
provides a manual escape hatch.

Note: GitHub Actions does NOT support pull_request_review_thread as
a trigger event, so clicking "Resolve conversation" in the UI does
not auto-fire this workflow. The check refreshes on the next push,
review, or comment. This gap is documented inline in the workflow
file. (Library repo #621 was the hotfix that taught us this.)

Files touched

Diff

commit 21ae8a5407c27d1984c2db7ae917d2903e56fe00
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Sat May 16 14:34:36 2026 -0700

    feat(ci): surface unresolved review threads as a status check
    
    Ports the conversation-resolution-check workflow from the downstream
    library repo (mvanhorn/printing-press-library#620, #621). The gate
    itself is enforced by Mergify (#1536 adds the
    `#review-threads-unresolved = 0` condition to .mergify.yml). This
    workflow is the visibility layer.
    
    Why both: Mergify's existing `Mergify Merge Protections` check_run
    is the most readable surface for "what's missing" after a maintainer
    applies `ready-to-merge`, but pre-label it just says "skipping" with
    no specific signal about thread state. An author who pushed a fix
    but didn't click "Resolve conversation" sees green CI, green Greptile
    Review, and no obvious cue. This workflow puts an explicit
    "All conversations resolved" check_run in the status list regardless
    of label state.
    
    Lifecycle coverage: pull_request_target for new commits,
    pull_request_review for new reviews from Greptile, and
    pull_request_review_comment for new inline findings.
    concurrency.cancel-in-progress coalesces bursts. Workflow_dispatch
    provides a manual escape hatch.
    
    Note: GitHub Actions does NOT support pull_request_review_thread as
    a trigger event, so clicking "Resolve conversation" in the UI does
    not auto-fire this workflow. The check refreshes on the next push,
    review, or comment. This gap is documented inline in the workflow
    file. (Library repo #621 was the hotfix that taught us this.)
---
 .../workflows/conversation-resolution-check.yml    | 163 +++++++++++++++++++++
 1 file changed, 163 insertions(+)

diff --git a/.github/workflows/conversation-resolution-check.yml b/.github/workflows/conversation-resolution-check.yml
new file mode 100644
index 00000000..fe9c78b6
--- /dev/null
+++ b/.github/workflows/conversation-resolution-check.yml
@@ -0,0 +1,163 @@
+name: Conversation resolution check
+
+# Surfaces unresolved review threads as a status check on the PR head SHA.
+# Not an enforcement gate — Mergify's `#review-threads-unresolved = 0`
+# condition in .mergify.yml is what actually blocks the merge queue. This
+# workflow exists for pre-label visibility: Mergify's `Mergify Merge
+# Protections` check_run is the most readable surface for "what's missing"
+# AFTER `ready-to-merge` is applied, but pre-label it just says
+# "skipping" / "no merge protections matched" and gives no specific
+# signal about thread state.
+#
+# So an author waiting on review who already pushed a fix but didn't
+# click "Resolve conversation" sees green CI checks, a green Greptile
+# Review, and no specific cue that unresolved threads are the holdup.
+# This workflow puts that cue in the same place authors look — the
+# status checks list — and keeps it there regardless of label state.
+
+on:
+  pull_request_target:
+    types:
+      - opened
+      - reopened
+      - synchronize
+      - ready_for_review
+  pull_request_review:
+    types: [submitted]
+  pull_request_review_comment:
+    types: [created, deleted]
+  # Note: GitHub Actions does NOT support a `pull_request_review_thread`
+  # event trigger (despite that event existing in the webhook payload set).
+  # So clicking "Resolve conversation" in the UI does not auto-fire this
+  # workflow. The check_run will refresh on the next push, review, or
+  # comment event. workflow_dispatch is the manual escape hatch.
+  workflow_dispatch:
+    inputs:
+      pr:
+        description: Pull request number to evaluate
+        required: true
+
+permissions:
+  checks: write
+  contents: read
+  pull-requests: read
+
+jobs:
+  evaluate:
+    name: All conversations resolved
+    if: github.event_name != 'pull_request_target' || github.event.pull_request.draft == false
+    runs-on: ubuntu-latest
+    timeout-minutes: 5
+    # Coalesce bursts (a push + Greptile review + several thread resolves can
+    # fire within seconds). Latest run wins; older runs are cancelled.
+    concurrency:
+      group: conversation-resolution-${{ github.event.pull_request.number || inputs.pr || github.run_id }}
+      cancel-in-progress: true
+    env:
+      GH_TOKEN: ${{ github.token }}
+    steps:
+      - name: Resolve PR number + head SHA
+        id: pr
+        env:
+          DISPATCH_PR: ${{ inputs.pr }}
+          EVENT_PR_NUMBER: ${{ github.event.pull_request.number }}
+          EVENT_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
+        run: |
+          set -euo pipefail
+          if [ -n "${EVENT_PR_NUMBER:-}" ] && [ -n "${EVENT_HEAD_SHA:-}" ]; then
+            echo "number=${EVENT_PR_NUMBER}" >> "${GITHUB_OUTPUT}"
+            echo "head_sha=${EVENT_HEAD_SHA}" >> "${GITHUB_OUTPUT}"
+            exit 0
+          fi
+          if ! [[ "${DISPATCH_PR:-}" =~ ^[0-9]+$ ]]; then
+            echo "::error::PR number not in event payload and 'pr' input is not a positive integer."
+            exit 1
+          fi
+          pr_json="$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${DISPATCH_PR}")"
+          echo "number=$(jq -r '.number' <<<"${pr_json}")" >> "${GITHUB_OUTPUT}"
+          echo "head_sha=$(jq -r '.head.sha' <<<"${pr_json}")" >> "${GITHUB_OUTPUT}"
+
+      - name: Evaluate review threads and post check_run
+        env:
+          PR_NUMBER: ${{ steps.pr.outputs.number }}
+          HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
+        run: |
+          set -euo pipefail
+
+          owner="${GITHUB_REPOSITORY%/*}"
+          repo="${GITHUB_REPOSITORY#*/}"
+
+          threads_json="$(mktemp)"
+          gh api graphql --paginate \
+            -F owner="${owner}" -F repo="${repo}" -F number="${PR_NUMBER}" \
+            -f query='
+              query($owner: String!, $repo: String!, $number: Int!, $endCursor: String) {
+                repository(owner: $owner, name: $repo) {
+                  pullRequest(number: $number) {
+                    reviewThreads(first: 100, after: $endCursor) {
+                      pageInfo { hasNextPage endCursor }
+                      nodes {
+                        isResolved
+                        isOutdated
+                        path
+                        line
+                        comments(first: 1) { nodes { author { login } } }
+                      }
+                    }
+                  }
+                }
+              }
+            ' \
+            --jq '.data.repository.pullRequest.reviewThreads.nodes[]' \
+            | jq -s '.' > "${threads_json}"
+
+          total=$(jq 'length' "${threads_json}")
+          unresolved=$(jq '[.[] | select(.isResolved == false)] | length' "${threads_json}")
+          unresolved_active=$(jq '[.[] | select(.isResolved == false and .isOutdated == false)] | length' "${threads_json}")
+          unresolved_outdated=$((unresolved - unresolved_active))
+
+          echo "total=${total} unresolved=${unresolved} (active=${unresolved_active} outdated=${unresolved_outdated})"
+
+          if [ "${unresolved}" -eq 0 ]; then
+            conclusion="success"
+            title="All ${total} conversation(s) resolved"
+            summary=$'No unresolved review threads on this PR.'
+          else
+            conclusion="failure"
+            if [ "${unresolved_active}" -gt 0 ] && [ "${unresolved_outdated}" -gt 0 ]; then
+              title="${unresolved} unresolved (${unresolved_active} on current code, ${unresolved_outdated} outdated)"
+            elif [ "${unresolved_active}" -gt 0 ]; then
+              title="${unresolved} unresolved conversation(s)"
+            else
+              title="${unresolved} unresolved on outdated lines (likely addressed)"
+            fi
+
+            summary=$(jq -r '
+              def listing:
+                [.[] | select(.isResolved == false)] as $u |
+                ($u | length) as $count |
+                "**" + ($count|tostring) + " unresolved review thread(s):**\n\n" +
+                ([$u[0:10] | .[] |
+                  "- `" + .path + (if .line then ":" + (.line | tostring) else "" end) + "` by `" +
+                  (.comments.nodes[0].author.login // "unknown") + "`" +
+                  (if .isOutdated then " *(outdated — line no longer exists; likely addressed by a later commit)*" else "" end)
+                ] | join("\n")) +
+                (if $count > 10 then "\n\n_… and " + ($count - 10 | tostring) + " more._" else "" end) +
+                "\n\n---\n\n**To unblock the merge**, click \"Resolve conversation\" on each unresolved thread in the GitHub UI.\n\nMerge is gated by Mergify (`#review-threads-unresolved = 0` in `.mergify.yml`); this check is the visibility layer so the blocker shows up here regardless of whether the `ready-to-merge` label has been applied yet."
+              ;
+              listing
+            ' --arg PR_NUMBER "${PR_NUMBER}" "${threads_json}")
+          fi
+
+          payload="$(mktemp)"
+          jq -n \
+            --arg name "All conversations resolved" \
+            --arg head_sha "${HEAD_SHA}" \
+            --arg conclusion "${conclusion}" \
+            --arg title "${title}" \
+            --arg summary "${summary}" \
+            '{name: $name, head_sha: $head_sha, status: "completed", conclusion: $conclusion, output: {title: $title, summary: $summary}}' \
+            > "${payload}"
+
+          gh api -X POST "repos/${GITHUB_REPOSITORY}/check-runs" --input "${payload}" >/dev/null
+          echo "Posted check_run: ${title} (${conclusion}) on ${HEAD_SHA}"

← a8fa80a3 fix(ci): require all review threads resolved before queue +  ·  back to Cli Printing Press  ·  fix(ci): address Greptile review on conversation-resolution f55f57a8 →