Skip to content

Reusable workflow: resolve_bot_pr_threads.yml — let the review bot resolve threads it marks addressed #84

@rtibblesbot

Description

@rtibblesbot

This issue is not open for contribution. Visit Contributing guidelines to learn about the contributing process and how to find suitable issues.

Summary

Add an org-wide reusable workflow in this repo — resolve_bot_pr_threads.yml — called from our other repos, that lets our automated review bot (rtibblesbot) resolve the review threads it has marked as addressed — something it currently cannot do directly because it has no write access to our repos.

This is the repo-side half of a two-part design. The bot-side half lands in the coding-agent review pipeline.

Background / problem

rtibblesbot posts automated code reviews on PRs across our repos (kolibri, studio, kolibri-design-system, …). On a delta re-review it correctly classifies each prior finding (RESOLVED / ACKNOWLEDGED / CONTESTED / UNADDRESSED) and already emits a collapsed "Prior-finding status" summary in the review body.

What it cannot do is resolve the corresponding conversation threads in the GitHub UI. Every attempt fails:

gh: rtibblesbot does not have the correct permissions to execute `ResolveReviewThread`

Root cause: resolveReviewThread requires the actor to have write (push) access to the base repo (or be the PR author). rtibblesbot is an external reviewer with neither — and most of our PRs come from forks (e.g. rtibbles/kolibrilearningequality/kolibri). We do not want to grant the bot write access, nor stand up a GitHub App just for this.

Proposed design

Split the privileged action to where the privilege already exists:

  1. Bot side (in coding-agent): instead of calling resolveReviewThread, the bot edits its own root review comment for each settled thread (author permission — no write access needed) to inject:

    • a visible marker, e.g. ✅ **Resolved** — addressed in current code, and
    • a hidden HTML-comment marker <!-- agent:thread-resolved --> (invisible in rendered markdown, greppable). Idempotent: skip if already present.
  2. Repo side (this request): an automation, triggered by that comment edit, that runs inside the repo's own Actions context — where GITHUB_TOKEN carries write permission — finds the thread whose root comment is the edited one, and resolves it. Gated on the comment author being rtibblesbot and the marker being present.

Result: the cosmetic ✅ shows immediately, and the thread genuinely collapses / the unresolved-conversation count drops — with no extra permissions granted to the bot.

Why this works on fork PRs (permissions research)

The load-bearing question was whether the Actions GITHUB_TOKEN can resolve a thread when the PR is from a fork. It can:

  • The read-only-token-on-forks downgrade is specific to the pull_request event. Comment/review events (issue_comment, pull_request_review, pull_request_review_comment) instead run from the base repo's default branch with a writable token, even for fork PRs. (peter-evans/create-pull-request concepts guide; Wiz GitHub Actions threat model.)
  • resolveReviewThread requires contents: write on the token (not merely pull-requests: write) — confirmed in GitHub community discussion #44650. The caller grants both.

Security

These comment-triggered events are powerful precisely because they grant a write token to externally-triggered runs. The standard exploit requires the workflow to check out and run PR-head code — this automation never checks out anything. It runs one fixed GraphQL mutation, gated on a trusted author login + the hidden marker. No untrusted code path.

Deployment: reusable workflow + per-repo caller

We propagate org automations as reusable workflows called from each repo, so that's the shape used here: a dedicated reusable workflow resolve_bot_pr_threads.yml in this repo, plus a thin caller on each target repo's default branch. (Note: org "required workflows" / rulesets-require-workflows are not an option — they require GitHub Enterprise, which we don't have, and they're a merge gate on pull_request, not event-triggered automation. Not applicable.)

Reusable workflow (in this repo)

.github/workflows/resolve_bot_pr_threads.yml. Because the github context inside a reusable workflow reflects the caller's original event, the author/marker gate lives here:

# .github/workflows/resolve_bot_pr_threads.yml
on:
  workflow_call:

jobs:
  resolve-addressed-review-threads:
    if: >
      github.event_name == 'pull_request_review_comment' &&
      github.event.comment.user.login == 'rtibblesbot' &&
      contains(github.event.comment.body, '<!-- agent:thread-resolved -->')
    runs-on: ubuntu-latest
    steps:
      - name: Resolve the thread containing this comment
        env:
          GH_TOKEN: ${{ github.token }}
          OWNER: ${{ github.event.repository.owner.login }}
          NAME: ${{ github.event.repository.name }}
          PR: ${{ github.event.pull_request.number }}
          COMMENT_ID: ${{ github.event.comment.id }}
        run: |
          set -euo pipefail
          thread_id=$(gh api graphql -f query='
            query($owner:String!,$name:String!,$pr:Int!){
              repository(owner:$owner,name:$name){
                pullRequest(number:$pr){
                  reviewThreads(first:100){
                    nodes{ id isResolved comments(first:1){ nodes{ databaseId } } }
                  }
                }
              }
            }' -F owner="$OWNER" -F name="$NAME" -F pr="$PR" \
            --jq ".data.repository.pullRequest.reviewThreads.nodes[]
                  | select(.isResolved==false)
                  | select(.comments.nodes[0].databaseId==$COMMENT_ID) | .id")
          if [ -z "$thread_id" ]; then
            echo "No matching unresolved thread for comment $COMMENT_ID"; exit 0
          fi
          gh api graphql -f query='
            mutation($id:ID!){ resolveReviewThread(input:{threadId:$id}){ thread{ id isResolved } } }' \
            -f id="$thread_id"
          echo "Resolved thread $thread_id"

Per-repo caller (on each repo's default branch)

The caller owns the trigger and grants the token permissions the reusable workflow needs:

name: Automation
on:
  pull_request_review_comment:
    types: [edited]
  # ...any other events the consolidated automation handles...
jobs:
  resolve-bot-pr-threads:
    permissions:
      contents: write        # required by resolveReviewThread (gh community #44650)
      pull-requests: write
    uses: learningequality/.github/.github/workflows/resolve_bot_pr_threads.yml@main
    secrets: inherit

A dedicated caller (rather than folding into a future catch-all entry point) keeps the contents: write grant scoped to just this automation.

Notes:

  • reviewThreads(first:100) covers all but very large PRs; add pagination if needed.
  • github.event.comment.id (REST id) equals the GraphQL databaseId, so the match is exact.
  • No loop risk: resolving a thread doesn't emit a comment-edit event.

Validation plan

  1. Land the reusable job here + a caller on one repo's default branch (suggest kolibri) to prove the fork-PR token can resolve.
  2. Manually edit one existing rtibblesbot review comment to add the marker → confirm the thread resolves.
  3. Once proven, ship the bot-side comment-edit change in coding-agent and add the caller to the remaining repos.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions