Building a slash-command bot for pull requests Jump to heading

Some actions on a pull request are needed occasionally and do not deserve a button in every workflow: re-run the flaky integration suite, rebase on the latest main, deploy a preview, backport to a release branch. Teams end up either doing them by hand from a terminal or adding special labels whose meaning nobody remembers. A slash-command bot lets maintainers type /retest or /backport release/2.4 in a pull request comment and have automation do it. It is a few dozen lines of workflow — but because it acts on comments anyone can write, it is also an easy way to hand privileges to strangers if permissions and inputs are not checked carefully. This page builds a safe one, within pull request automation and bots.

When to use this approach Jump to heading

  • Maintainers regularly perform the same few manual actions on pull requests.
  • You want those actions available to people without terminal access to the repository.
  • You need an audit trail of who triggered what, visible on the pull request.
  • Manual workflows already exist and need a friendlier front end, such as manual dispatch workflows with inputs.

Step 1 — Listen to comments and parse the command Jump to heading

Pull request comments arrive as issue_comment events. Filter to comments on pull requests that start with a slash, and extract the command and its arguments.

# .github/workflows/slash-commands.yml
on:
  issue_comment:
    types: [created]
permissions: { contents: read, pull-requests: write, actions: write }
jobs:
  command:
    if: github.event.issue.pull_request && startsWith(github.event.comment.body, '/')
    runs-on: ubuntu-latest
    steps:
      - id: parse
        env: { BODY: "${{ github.event.comment.body }}" }
        run: |
          first=$(printf '%s\n' "$BODY" | head -1 | tr -s ' ')
          cmd=$(printf '%s' "$first" | cut -d' ' -f1 | tr -d '/')
          arg=$(printf '%s' "$first" | cut -s -d' ' -f2)
          echo "cmd=$cmd" >> "$GITHUB_OUTPUT"; echo "arg=$arg" >> "$GITHUB_OUTPUT"

The comment body is untrusted text. It is read through an environment variable, never interpolated into the script, for the same reason as manual-dispatch inputs.

Step 2 — Check that the commenter is allowed Jump to heading

Anyone who can comment can type a command, including outside contributors on public repositories. Check the commenter’s permission on the repository before doing anything.

      - id: perm
        run: |
          level=$(gh api "repos/$GITHUB_REPOSITORY/collaborators/$ACTOR/permission" --jq .permission)
          case "$level" in admin|maintain|write) echo "ok=true" >> "$GITHUB_OUTPUT" ;;
            *) echo "ok=false" >> "$GITHUB_OUTPUT" ;; esac
        env: { GH_TOKEN: "${{ github.token }}", ACTOR: "${{ github.event.comment.user.login }}" }
      - if: steps.perm.outputs.ok != 'true'
        run: gh pr comment "$PR" --body "@$ACTOR slash commands are available to maintainers only."
             && exit 1
        env: { GH_TOKEN: "${{ github.token }}", ACTOR: "${{ github.event.comment.user.login }}", PR: "${{ github.event.issue.number }}" }
Handling a slash command safelyA maintainer comments /retest on a pull request. The workflow parses the comment from an environment variable, checks the commenter's repository permission, validates the command against an allow-list, reacts to show it was received, dispatches the action and reports the result back on the pull request.maintainerworkflowGitHub APIaction/retestpermission of commenter?writedispatch allowed command👍 + result commentthe permission check comes before any action — a comment is not an authorisation

Step 3 — Dispatch only allow-listed commands Jump to heading

Map each command to a fixed action. Validate arguments against strict patterns or lists. Unknown commands get a helpful reply rather than silence.

      - if: steps.perm.outputs.ok == 'true'
        env:
          GH_TOKEN: ${{ github.token }}
          CMD: ${{ steps.parse.outputs.cmd }}
          ARG: ${{ steps.parse.outputs.arg }}
          PR: ${{ github.event.issue.number }}
        run: |
          head=$(gh pr view "$PR" --json headRefName --jq .headRefName)
          gh api -X POST "repos/$GITHUB_REPOSITORY/issues/comments/${{ github.event.comment.id }}/reactions" -f content=eyes >/dev/null
          case "$CMD" in
            retest)
              gh run list --branch "$head" --limit 1 --json databaseId --jq '.[0].databaseId' |
                xargs -I{} gh run rerun {} --failed ;;
            backport)
              printf '%s' "$ARG" | grep -Eq '^release/[0-9]+\.[0-9]+$' || { gh pr comment "$PR" --body "Usage: /backport release/X.Y"; exit 1; }
              gh pr edit "$PR" --add-label "backport $ARG" ;;
            deploy-preview)
              gh workflow run preview.yml -f pr="$PR" ;;
            *)
              gh pr comment "$PR" --body "Unknown command \`/$CMD\`. Available: /retest, /backport release/X.Y, /deploy-preview"; exit 1 ;;
          esac

The backport command adds a label that drives the workflow from automating backport pull requests with labels, so the bot composes with automation you already have.

Step 4 — Never run the pull request’s code with the bot’s privileges Jump to heading

issue_comment workflows run from the default branch with the repository’s token and secrets — like pull_request_target. A /test command that checks out the pull request’s head and runs its build would execute an outsider’s code with those privileges. Commands that need to run pull request code should trigger an unprivileged workflow instead, or require that the commenter has reviewed and approved the exact commit.

Safe and unsafe slash-command designsSafe commands act on metadata or dispatch other workflows that run with appropriate privileges: re-running checks, adding labels, deploying an already-reviewed commit. Unsafe commands check out the pull request head and run its scripts inside the privileged comment-triggered workflow.SafeUnsafe/retestre-run existing runscheckout PR, run tests here/backportadd a labelcherry-pick with secrets/deploy-previewdispatch with reviewed SHAbuild PR head with secretsthe comment workflow should orchestrate, never execute contributed code
# deploy-preview: pin the exact commit a maintainer reviewed
      - run: |
          sha=$(gh pr view "$PR" --json headRefOid --jq .headRefOid)
          gh workflow run preview.yml -f pr="$PR" -f sha="$sha"

Step 5 — Report the result on the pull request Jump to heading

People need to see that the command worked. React to the comment when received, then post or update a result comment with a link to the run.

run_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"
gh pr comment "$PR" --body "✅ \`/$CMD\` done by @$ACTOR — [run]($run_url)"
A slash command from comment to resultA maintainer types the command. Within seconds the bot reacts to show it was seen. The permission and argument checks pass, the action is dispatched, and a result comment with a run link appears when it finishes, giving an audit trail on the pull request.Comment/backport release/2.40 sReaction👀 received5 sCheckspermission + args8 sDispatchedlabel added10 sResultbackport PR linked2 minvisible acknowledgement within seconds is what makes a bot feel reliable

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

Why not use an existing slash-command action? Jump to heading

Several exist and handle parsing well. The permission check and the rule about never running pull request code still need to be right in your configuration, so understand the pattern even if you use a library.

Can commands be restricted to specific people, not just write access? Jump to heading

Yes — check team membership instead of repository permission, for example gh api orgs/$ORG/teams/release-managers/memberships/$ACTOR.

How do we stop a command running twice on edited comments? Jump to heading

Listen only to created, not edited, comment events. If someone edits a comment to add a command, they can post a new comment instead.