How to stop a GitHub bot replying to its own comments
Your app posted a comment, GitHub sent you a webhook for that comment, your handler treated it as new input, and by the time you opened the logs the issue had forty comments on it.
This one is for people building against the GitHub API rather than filing issues into it. The fix is three checks, and the order they run in matters more than any one of them.
One turn of the loop
You post a comment. GitHub fires issue_comment with action created, as
it does for every comment on that issue including yours. Your handler reads a comment body it has
never seen before and does what it does with new input, which is answer it. That answer is a comment,
so GitHub fires again.
Each turn spends an API call, a model call if you have one, and a notification to everybody watching the thread. Nothing on GitHub's side is going to stop this for you.
Check one: drop the events you were never going to act on
Filter on the action field before you look at identity. It is the cheapest check available and it
needs nothing from the payload except one string. Accept the actions you actually act on, usually
opened and created, and return immediately on everything else.
Two cases catch people out. Editing your own comment fires edited on the same
endpoint, so a handler that ignores the action field will answer its own edits. And a comment in a
pull request's conversation arrives on the issue webhook, with a pull_request key
inside the issue object, which means a pull request will drive exactly the same loop an issue does.
Inline comments on the diff are the exception worth knowing: those are a different event,
pull_request_review_comment, which your issue handler never receives at all.
This check fixes the self-edit loop completely and fixes nothing else.
Check two: compare the identity, and know what it misses
This is the answer everybody finds first. Read the actor from issue.user on
opened and comment.user on created, then test whether
type is Bot or the login ends in [bot]. Write both arms rather
than picking one, because either can be the arm that matches on a given payload.
It is worth knowing where identity stops describing you. If you post with a user token rather than
as the app, your comment carries a human login. If a workflow of yours posts with
GITHUB_TOKEN, it arrives as github-actions[bot] and not as you. If the same
code runs under a second app identity in staging, neither identity knows about the other. The
identity question is a bigger subject than this page, and the
cases where the [bot] check misses is where it gets its own treatment.
One thing to get right while you are in here: a payload with no user object must
produce a decision rather than an exception. A handler that throws has not deferred that event, it
has lost it, because a failed delivery does not
come round again.
Check three: a hidden marker in your own comment body
The defence that holds when identity does not is to mark the thing you post. Put a fixed string in the body of every comment your app writes, and test incoming bodies for it.
An HTML comment such as <!-- your-app-name -->, on a line of its own, does the
job. It is invisible in GitHub's rendered markdown, so nobody reading the thread ever sees it, and it
survives verbatim into comment.body on the webhook. The check is one line: does the
incoming body contain that string.
The reason this beats identity is that it is a property of the comment rather than of the account. Whether you posted with an app token or a user token, from production or from staging, under one app or two, the marker is in the body you wrote and it comes back to you unchanged.
Test with contains, and resist tightening that to ends with on the grounds that the marker is the last line. It is the last line of what your formatter builds, which is not the same as the last line of what gets posted. A welcome wrapped around the formatted text, or a notice appended to it on the way out, puts something after the marker, and an ends-with check then misses your own comment.
Contains carries a cost of its own: a person who quotes your whole comment in their reply carries your marker into their body, and you will skip them. Let it fall that way on purpose. Skipping one human reply is one person wondering why the bot went quiet. Missing your own comment is a loop in somebody else's repository.
The marker check has to come before the bot check
This is the part nobody writes down, and it is the part that bites.
If your bot branch posts anything at all, even a one-line notice saying you ignore automated issues, then your own comment reaching that branch posts a notice. The notice fires a webhook. Your own app is a bot, so the notice reaches the bot branch, and the bot branch posts a notice.
The rule generalises: order your checks by what the branch does, not by what reads logically. Any branch that speaks goes last. In practice that gives this order.
- Is this a pull request thread? Return silently.
- Does the body carry your marker? Return silently.
- Is the author a bot? Skip, and possibly explain.
- Otherwise, process it.
The first two return silence. Only the third can post, which is why it is third. Put a comment above it saying so, because the order looks arbitrary to the next person who tidies the handler.
Append the marker in one place
The rule you want is that the function which posts a comment is the function which appends the marker, so nothing can reach GitHub without going through it.
The easy way to get this wrong is to put the marker on inside the formatter that builds your main comments, and then by hand in each standalone notice: a quota message, an acknowledgement when somebody tells you to stop, a note explaining a skipped bot. Every one of those is a chance to forget, and the notice you forget is the one thing you post that you cannot recognise as your own.
The consequence is not silence. That comment comes back as a new event, fails the marker check, matches the bot check instead, and earns a public notice explaining that a comment written by a bot was skipped. The bot is you.
Moving a stray notice in beside its siblings fixes that one instance. It does not fix the next, because the next person to write a notice can still write it somewhere else. The test is the part that generalises, and the obvious test does not. Building a body by hand, appending the marker and asserting the filter drops it proves the marker works. It says nothing about whether the things you actually post carry it, which is the only question that matters. Enumerate every comment your code sends, pass each real string through the real filter, and fail on any one that comes back unrecognised. Write the test against the values your code produces, not against values that look like them.
The other loop: two bots, neither of which looks like one
Your marker is in your body, so it says nothing about somebody else's app answering you, and that
app may not look automated either. A workflow posting with GITHUB_TOKEN, or a human
account driven by a script, passes every identity test you have.
What helps is a ceiling. Count how many times you have already spoken on this thread and stop at a small number. Be honest with yourself about what it is doing: it detects nothing and prevents nothing. It bounds how much of a loop escapes before a person notices one.
If you explain the silence, scope it per repository
Dropping a bot silently is correct and completely invisible. A maintainer whose CI robot has gone quiet has no way to tell "deliberately ignored" from "broken", and will reasonably assume you broke.
So post a note, but scope it per repository with a long interval rather than per issue. One CI robot filing a hundred issues would otherwise leave a hundred identical comments in a tracker that somebody then has to clear by hand. Once per repository, with weeks rather than hours before another is allowed, is plenty. The note is itself machine-written comment volume, and that scope is the only thing keeping it small.
Six payloads to test before you deploy the fix
- A comment from a login ending in
[bot]with notypefield. - A comment from a user with
typeofBotand no suffix on the login. - A person's comment on an issue a bot opened, which must be answered.
- Your own comment, carrying your marker, from your own app login.
- Any comment on a pull request.
- A payload with no
userobject at all.
Two of them, the missing type field and the missing user object, are
guards rather than shapes you will often see arrive. Keep them anyway, for the reason above: the
failure mode is a lost event, not a retried one.
These are cheap to assert against, which matters, because a loop is one of the few bugs you genuinely cannot catch by reading the code. Every line involved is correct on its own. The defect is in which order they run.
One last thing, if your app files issues rather than answering them. Being automated does not excuse the report from being readable: it still lands in front of a person who has to decide what to do with it, and the same things make it actionable or not.
WhatProblem asks these questions for you
It reads new GitHub issues and asks what is missing, in the issue thread, before anyone on your team has to.
Install from GitHub Marketplace