#42868·PrestaShop

kanbanbot overwrites the whole label set when reacting to an approval, silently reverting concurrent label changes

Author: mattgoudCreated Sep 17, 2026Updated Sep 17, 2026

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:

  1. PullRequestApprovedStrategy::createCommandsFromPayload() dispatches AddLabelByApprovalCountCommand on every approval.
  2. AddLabelByApprovalCountCommandHandler::__invoke() does a find() (which GETs the current labels), calls addLabelByApprovalCount(), then update().
  3. RestPullRequestRepository::update() sends PATCH /repos/{owner}/{repo}/issues/{number} with the full labels array.

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

  1. Approve a pull request on PrestaShop/PrestaShop.
  2. Within a few seconds, change its labels, for instance gh pr edit <N> --remove-label "Waiting for author" --add-label "Waiting for QA".
  3. 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-jarvis

gh 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}/labels to 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:

php
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.