← back to Cli Printing Press

.github/workflows/conversation-resolution-check.yml

199 lines

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:
  contents: read
  pull-requests: read

jobs:
  evaluate:
    # GitHub Actions auto-creates a check_run with this exact name when
    # the job runs, and its conclusion mirrors the job's exit status.
    # We rely on that auto-created check_run rather than POSTing our
    # own because as of Feb 2025 the GitHub Actions runtime rejects
    # external workflow attempts to update check_run status/conclusion
    # via GITHUB_TOKEN (HTTP 403). The job exits non-zero when threads
    # are unresolved, the auto check_run goes red, the merge widget
    # shows the failure — no API calls, no duplicates, no PATCH races.
    name: All conversations resolved
    # Skip drafts across every event source. The original guard only
    # covered pull_request_target, but pull_request_review and
    # pull_request_review_comment events also carry a pull_request
    # object with .draft, and a review/comment on an in-progress draft
    # should not post a failure check_run. workflow_dispatch has no
    # pull_request in the payload, so we fall through and let the
    # job run; manual dispatch is an explicit ask.
    if: github.event.pull_request == null || github.event.pull_request.draft == false
    runs-on: ubuntu-latest
    timeout-minutes: 5
    # Serialize concurrent triggers via the concurrency group, but do NOT
    # cancel in-progress runs. Cancellation freezes the auto-created
    # check_run in `cancelled` state forever (we can't PATCH it post-Feb
    # 2025), and a single push commonly fires 3-5 events in seconds
    # (synchronize + Greptile's review.submitted + multiple
    # review_comment.created), which left a row of `cancelled` check_runs
    # making the PR look "partially failed" even when the real result
    # was success. With cancel-in-progress: false, queued runs execute
    # serially, each completes with a terminal success/failure, and the
    # required-check rollup uses the latest by completed_at. UI shows
    # N consistent entries instead of cancelled noise.
    concurrency:
      group: conversation-resolution-${{ github.event.pull_request.number || inputs.pr || github.run_id }}
      cancel-in-progress: false
    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 }}
          EVENT_NAME: ${{ github.event_name }}
          EVENT_ACTION: ${{ github.event.action }}
        run: |
          set -euo pipefail

          owner="${GITHUB_REPOSITORY%/*}"
          repo="${GITHUB_REPOSITORY#*/}"

          # synchronize fires the moment a push lands, but GitHub marks
          # threads outdated/resolved asynchronously based on the new
          # file content. Querying immediately races that resolution and
          # can report a stale "1 unresolved" for a thread that is about
          # to flip resolved a second later (printing-press-library #587
          # hit this). Short settle delay lets the auto-resolution land.
          # Scoped to synchronize only — opened, reopened, ready_for_review
          # don't move the commit pointer so they can't have new
          # auto-resolutions in flight (greptile P2 on #623).
          if [ "${EVENT_NAME}" = "pull_request_target" ] && [ "${EVENT_ACTION}" = "synchronize" ]; then
            sleep 8
          fi

          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
            printf '## All %s conversation(s) resolved\n\nNo unresolved review threads on this PR.\n' \
              "${total}" >> "${GITHUB_STEP_SUMMARY}"
            echo "PASS: 0 unresolved threads on ${HEAD_SHA}"
            exit 0
          fi

          # Build the detailed thread listing once, reused for the step
          # summary (rendered on the workflow run page) and the
          # ::error:: annotation (rendered in the check_run UI).
          listing="$(jq -r '
            [.[] | select(.isResolved == false)] as $u |
            ($u | length) as $count |
            ([$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)
          ' "${threads_json}")"

          if [ "${unresolved_active}" -gt 0 ] && [ "${unresolved_outdated}" -gt 0 ]; then
            heading="${unresolved} unresolved (${unresolved_active} on current code, ${unresolved_outdated} outdated)"
          elif [ "${unresolved_active}" -gt 0 ]; then
            heading="${unresolved} unresolved conversation(s)"
          else
            heading="${unresolved} unresolved on outdated lines (likely addressed)"
          fi

          {
            printf '## %s\n\n%s\n\n---\n\n' "${heading}" "${listing}"
            printf '**To unblock the merge**, click "Resolve conversation" on each unresolved thread in the GitHub UI. Per repo convention every Greptile finding should be resolved (in code, or with a concrete reply explaining the deferral) before merge.\n'
          } >> "${GITHUB_STEP_SUMMARY}"

          # ::error:: annotation surfaces in the check_run UI without
          # needing a POST. The job's non-zero exit drives the auto
          # check_run's conclusion to failure, which is what the merge
          # widget displays.
          echo "::error::${heading} on PR #${PR_NUMBER}. See the workflow run summary for the list. Click \"Resolve conversation\" on each thread to unblock."
          exit 1