← 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