kanbanbot overwrites the whole label set when reacting to an approval, silently reverting concurrent label changes
Describe the bug
When ps-jarvis reacts to a pull request approval, it rewrites the entire label set of the PR. Any label added or removed by a human in the few seconds the bot takes to react is silently reverted, with no trace other than an unlabeled entry in the timeline.
The call chain in PrestaShop/kanbanbot is:
PullRequestApprovedStrategy::createCommandsFromPayload()dispatchesAddLabelByApprovalCountCommandon every approval.AddLabelByApprovalCountCommandHandler::__invoke()does afind()(which GETs the current labels), callsaddLabelByApprovalCount(), thenupdate().RestPullRequestRepository::update()sendsPATCH /repos/{owner}/{repo}/issues/{number}with the fulllabelsarray.
That PATCH is a replacement, not a merge, so whatever the bot read a moment earlier becomes the new truth. It is a read-modify-write with no concurrency control on a resource that humans edit at exactly the same moment, since the natural reflex after approving is to update the labels.
This is not specific to one label or one rule. Any label is exposed, on any repository of the organisation where the bot is installed.
Steps to reproduce
- Approve a pull request on
PrestaShop/PrestaShop. - Within a few seconds, change its labels, for instance
gh pr edit <N> --remove-label "Waiting for author" --add-label "Waiting for QA". - Read the timeline:
gh api repos/PrestaShop/PrestaShop/issues/<N>/timeline --jq '.[] | select(.event=="labeled" or .event=="unlabeled") | "\(.created_at) \(.event) \(.label.name) by \(.actor.login)"'
Observed on #42830:
2026-09-17T16:35:16Z (APPROVED review submitted by mattgoud)
2026-09-17T16:35:23Z unlabeled Waiting for author by mattgoud
2026-09-17T16:35:23Z labeled Waiting for QA by mattgoud
2026-09-17T16:35:26Z unlabeled Waiting for QA by ps-jarvisgh pr edit performs the removal and the addition as two separate API calls. The bot's GET landed between them and read [Improvement, develop], then wrote that back three seconds later. What proves the read landed inside that window, rather than before the whole edit, is that Waiting for author was not restored by the PATCH: had the bot read before the removal, it would have put that label back and the timeline would show it.
Expected behavior
The bot only touches the labels its own rules are about, and leaves the rest of the label set alone.
Actual Result
Every approval event rewrites the complete label set from a snapshot that may already be stale by the time it is written.
Suggested fix
Use the dedicated label endpoints instead of the issue PATCH, so the write is scoped to the labels the bot actually decided on:
POST /repos/{owner}/{repo}/issues/{number}/labelsto add, which is additive and leaves the others untouched.DELETE /repos/{owner}/{repo}/issues/{number}/labels/{name}to remove a specific one.
This needs the aggregate to expose what it added and what it removed rather than a replacement list, since PullRequest::addLabelsByDescription() does legitimately remove labels (the array_diff on the branch and type labels). Two small collections on the aggregate, one to add and one to remove, would cover every current rule and make the write commutative with whatever a human is doing at that instant.
Second, related point
PullRequest::addLabelByApprovalCount() tests the approval count with a strict equality:
if (/* … */ && 2 === count($validApprovals)) {
$this->labels[] = 'Waiting for QA';
}Since AddLabelByApprovalCountCommand is dispatched only from PullRequestApprovedStrategy, the condition is evaluated only when an approval arrives. So once a PR is past two approvals, the label can never be derived again: if it is removed at that point, by the PATCH described above, by a maintainer, or by a review being dismissed and re-submitted, no later approval will restore it, because the count will be 3 and never 2 again. >= would make the rule idempotent and let it recover.
How widespread it is
Every approval on every repository where the bot is installed goes through this path. The damage is only visible when a human touches the labels inside the reaction window, which makes it rare, silent, and confusing when it happens: the label simply vanishes seconds after being set, and nothing in the bot's rules explains it.
Deployed code is affected: RestPullRequestRepository::update() and PullRequest::addLabelByApprovalCount() are identical in v1.13.0, v1.13.1 and main at the time of writing.
Source: PrestaShop/PrestaShop