Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
52.00% covered (warning)
52.00%
130 / 250
33.33% covered (danger)
33.33%
10 / 30
CRAP
0.00% covered (danger)
0.00%
0 / 1
RevisionReviewForm
52.00% covered (warning)
52.00%
130 / 250
33.33% covered (danger)
33.33%
10 / 30
1276.27
0.00% covered (danger)
0.00%
0 / 1
 initialize
50.00% covered (danger)
50.00%
1 / 2
0.00% covered (danger)
0.00%
0 / 1
2.50
 getTitle
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setTitle
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setAction
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setLastChangeTime
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getNewLastChangeTime
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getRefId
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setRefId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getOldId
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setOldId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setTemplateParams
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setValidatedParams
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getComment
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setComment
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setDim
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setTag
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 getTags
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 setSessionKey
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 bypassValidationKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 doCheckTargetGiven
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 doBuildOnReady
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 doCheckTarget
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
4.07
 doCheckParameters
60.71% covered (warning)
60.71%
17 / 28
0.00% covered (danger)
0.00%
0 / 1
28.64
 isAllowed
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 getAction
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 doSubmit
83.78% covered (warning)
83.78%
93 / 111
0.00% covered (danger)
0.00%
0 / 1
40.22
 approveRevision
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
56
 unapproveRevision
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 validationKey
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 updateRecentChanges
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
110
1<?php
2
3use MediaWiki\Extension\Notifications\Model\Event;
4use MediaWiki\MediaWikiServices;
5use MediaWiki\Registration\ExtensionRegistry;
6use MediaWiki\Revision\RevisionRecord;
7use MediaWiki\Revision\SlotRecord;
8use MediaWiki\Title\Title;
9use Wikimedia\Rdbms\IDBAccessObject;
10
11/**
12 * Class containing revision review form business logic
13 */
14class RevisionReviewForm extends FRGenericSubmitForm {
15
16    public const ACTION_APPROVE = 'approve';
17    public const ACTION_UNAPPROVE = 'unapprove';
18    public const ACTION_REJECT = 'reject';
19
20    /** @var Title|null Target title object */
21    private $title = null;
22    /** @var FlaggableWikiPage|null Target page object */
23    private $page = null;
24    /** @var string|null One of the self::ACTION_… constants */
25    private $action = null;
26    /** @var int ID being reviewed (last "bad" ID for rejection) */
27    private $oldid = 0;
28    /** @var int Old, "last good", ID (used for rejection) */
29    private $refid = 0;
30    /** @var string Included template versions (flat string) */
31    private $templateParams = '';
32    /** @var string Parameter key */
33    private $validatedParams = '';
34    /** @var string Review comments */
35    private $comment = '';
36    /** Review tag (for approval) */
37    private ?int $tag = null;
38    /** @var string|null Conflict handling */
39    private $lastChangeTime = null;
40    /** @var string|null Conflict handling */
41    private $newLastChangeTime = null;
42
43    /** @var FlaggedRevision|null Prior FlaggedRevision for Rev with ID $oldid */
44    private $oldFrev = null;
45
46    /** @var string User session key */
47    private $sessionKey = '';
48    /** @var bool Skip validatedParams check */
49    private $skipValidationKey = false;
50
51    protected function initialize() {
52        if ( FlaggedRevs::useOnlyIfProtected() ) {
53            $this->tag = 0; // default to "inadequate"
54        }
55    }
56
57    /**
58     * @return Title|null
59     */
60    public function getTitle() {
61        return $this->title;
62    }
63
64    /**
65     * @param Title $value
66     */
67    public function setTitle( Title $value ) {
68        $this->trySet( $this->title, $value );
69    }
70
71    /**
72     * @param string $action One of the self::ACTION… constants
73     */
74    public function setAction( string $action ) {
75        $this->trySet( $this->action, $action );
76    }
77
78    /**
79     * @param string|null $value
80     */
81    public function setLastChangeTime( $value ) {
82        $this->trySet( $this->lastChangeTime, $value );
83    }
84
85    /**
86     * @return string|null
87     */
88    public function getNewLastChangeTime() {
89        return $this->newLastChangeTime;
90    }
91
92    /**
93     * @return int
94     */
95    public function getRefId() {
96        return $this->refid;
97    }
98
99    /**
100     * @param int $value
101     */
102    public function setRefId( $value ) {
103        $this->trySet( $this->refid, (int)$value );
104    }
105
106    /**
107     * @return int
108     */
109    public function getOldId() {
110        return $this->oldid;
111    }
112
113    /**
114     * @param int $value
115     */
116    public function setOldId( $value ) {
117        $this->trySet( $this->oldid, (int)$value );
118    }
119
120    /**
121     * @param string $value
122     */
123    public function setTemplateParams( $value ) {
124        $this->trySet( $this->templateParams, $value );
125    }
126
127    /**
128     * @param string $value
129     */
130    public function setValidatedParams( $value ) {
131        $this->trySet( $this->validatedParams, $value );
132    }
133
134    /**
135     * @return string
136     */
137    public function getComment() {
138        return $this->comment;
139    }
140
141    /**
142     * @param string $value
143     */
144    public function setComment( $value ) {
145        $this->trySet( $this->comment, $value );
146    }
147
148    /**
149     * @param int $value
150     * @deprecated Use setTag() instead.
151     */
152    public function setDim( $value ) {
153        $this->setTag( (int)$value );
154    }
155
156    public function setTag( ?int $value ): void {
157        if ( !FlaggedRevs::useOnlyIfProtected() ) {
158            $this->trySet( $this->tag, $value );
159        }
160    }
161
162    /**
163     * Get tags array, for usage with code that expects an array of tags
164     * rather than a single tag.
165     * @return array<string,int>
166     */
167    private function getTags(): array {
168        return $this->tag !== null ? [ FlaggedRevs::getTagName() => $this->tag ] : [];
169    }
170
171    /**
172     * @param string $sessionId
173     */
174    public function setSessionKey( $sessionId ) {
175        $this->sessionKey = $sessionId;
176    }
177
178    public function bypassValidationKey() {
179        $this->skipValidationKey = true;
180    }
181
182    /**
183     * Check that a target is given (e.g. from GET/POST request)
184     * @return true|string true on success, error string on failure
185     */
186    protected function doCheckTargetGiven() {
187        if ( $this->title === null ) {
188            return 'review_page_invalid';
189        }
190        return true;
191    }
192
193    /**
194     * Load any objects after ready() called
195     */
196    protected function doBuildOnReady() {
197        $this->page = FlaggableWikiPage::getTitleInstance( $this->title );
198    }
199
200    /**
201     * Check that the target is valid (e.g. from GET/POST request)
202     * @param int $flags FOR_SUBMISSION (set on submit)
203     * @return true|string true on success, error string on failure
204     */
205    protected function doCheckTarget( $flags = 0 ) {
206        $flgs = ( $flags & self::FOR_SUBMISSION ) ? IDBAccessObject::READ_LATEST : 0;
207        if ( !$this->title->getArticleID( $flgs ) ) {
208            return 'review_page_notexists';
209        }
210        if ( !$this->page->isReviewable() ) {
211            return 'review_page_unreviewable';
212        }
213        return true;
214    }
215
216    /**
217     * Validate and clean up parameters (e.g. from POST request).
218     * @return true|string true on success, error string on failure
219     */
220    protected function doCheckParameters() {
221        $action = $this->getAction();
222        if ( $action === null ) {
223            return 'review_param_missing'; // no action specified (approve, reject, de-approve)
224        } elseif ( !$this->oldid ) {
225            return 'review_no_oldid'; // no revision target
226        }
227        # Get the revision's current flags (if any)
228        $this->oldFrev = FlaggedRevision::newFromTitle( $this->title, $this->oldid, IDBAccessObject::READ_LATEST );
229        $oldTag = $this->oldFrev ? $this->oldFrev->getTag() : FlaggedRevision::getDefaultTag();
230        # Set initial value for newLastChangeTime (if unchanged on submit)
231        $this->newLastChangeTime = $this->lastChangeTime;
232        # Fill in implicit tag for binary flag case
233        if ( FlaggedRevs::binaryFlagging() ) {
234            if ( $this->action === self::ACTION_APPROVE ) {
235                $this->tag = 1;
236            } elseif ( $this->action === self::ACTION_UNAPPROVE ) {
237                $this->tag = 0;
238            }
239        }
240        if ( $action === self::ACTION_APPROVE ) {
241            # The tag should not be zero
242            if ( $this->tag === 0 ) {
243                return 'review_too_low';
244            }
245            # Special token to discourage fiddling with templates...
246            if ( !$this->skipValidationKey ) {
247                $k = self::validationKey( $this->oldid, $this->sessionKey );
248                if ( $this->validatedParams !== $k ) {
249                    return 'review_bad_key';
250                }
251            }
252            # Sanity check tag
253            if ( !FlaggedRevs::tagIsValid( $this->tag ) ) {
254                return 'review_bad_tags';
255            }
256            # Check permissions with tag
257            if ( !FlaggedRevs::userCanSetTag( $this->user, $this->tag, $oldTag ) ) {
258                return 'review_denied';
259            }
260        } elseif ( $action === self::ACTION_UNAPPROVE ) {
261            # Check permissions with old tag
262            if ( !FlaggedRevs::userCanSetTag( $this->user, $oldTag ) ) {
263                return 'review_denied';
264            }
265        }
266        return true;
267    }
268
269    /**
270     * @return bool
271     */
272    private function isAllowed() {
273        // Basic permission check
274        return ( $this->title && MediaWikiServices::getInstance()->getPermissionManager()
275            ->userCan( 'review', $this->user, $this->title ) );
276    }
277
278    /**
279     * Get the action this submission is requesting
280     * @return string|null (approve,unapprove,reject)
281     */
282    public function getAction(): ?string {
283        return $this->action;
284    }
285
286    /**
287     * Submit the form parameters for the page config to the DB.
288     *
289     * @return true|string true on success, error string on failure
290     */
291    protected function doSubmit() {
292        # Double-check permissions
293        if ( !$this->isAllowed() ) {
294            return 'review_denied';
295        }
296        $user = $this->user;
297        # We can only approve actual revisions...
298        $services = MediaWikiServices::getInstance();
299        $revStore = $services->getRevisionStore();
300        if ( $this->getAction() === self::ACTION_APPROVE ) {
301            $revRecord = $revStore->getRevisionByTitle( $this->title, $this->oldid );
302            # Check for archived/deleted revisions...
303            if ( !$revRecord || $revRecord->isDeleted( RevisionRecord::DELETED_TEXT ) ) {
304                return 'review_bad_oldid';
305            }
306            # Check for review conflicts...
307            if ( $this->lastChangeTime !== null ) { // API uses null
308                $lastChange = $this->oldFrev ? $this->oldFrev->getTimestamp() : '';
309                if ( $lastChange !== $this->lastChangeTime ) {
310                    return 'review_conflict_oldid';
311                }
312            }
313            $this->approveRevision( $revRecord, $this->oldFrev );
314            $status = true;
315        # We can only unapprove approved revisions...
316        } elseif ( $this->getAction() === self::ACTION_UNAPPROVE ) {
317            # Check for review conflicts...
318            if ( $this->lastChangeTime !== null ) { // API uses null
319                $lastChange = $this->oldFrev ? $this->oldFrev->getTimestamp() : '';
320                if ( $lastChange !== $this->lastChangeTime ) {
321                    return 'review_conflict_oldid';
322                }
323            }
324            # Check if we can find this flagged rev...
325            if ( !$this->oldFrev ) {
326                return 'review_not_flagged';
327            }
328            $this->unapproveRevision( $this->oldFrev );
329            $status = true;
330        } elseif ( $this->getAction() === self::ACTION_REJECT ) {
331            $newRevRecord = $revStore->getRevisionByTitle( $this->title, $this->oldid );
332            $oldRevRecord = $revStore->getRevisionByTitle( $this->title, $this->refid );
333            # Do not mess with archived/deleted revisions
334            if ( !$oldRevRecord ||
335                $oldRevRecord->isDeleted( RevisionRecord::DELETED_TEXT ) ||
336                !$newRevRecord ||
337                $newRevRecord->isDeleted( RevisionRecord::DELETED_TEXT )
338            ) {
339                return 'review_bad_oldid';
340            }
341            # Check that the revs are in order
342            if ( $oldRevRecord->getTimestamp() > $newRevRecord->getTimestamp() ) {
343                return 'review_cannot_undo';
344            }
345            # Make sure we are only rejecting pending changes
346            $srev = FlaggedRevision::newFromStable( $this->title, IDBAccessObject::READ_LATEST );
347            if ( $srev && $oldRevRecord->getTimestamp() < $srev->getRevTimestamp() ) {
348                return 'review_cannot_reject'; // not really a use case
349            }
350            $article = $services->getWikiPageFactory()->newFromTitle( $this->title );
351            # Get text with changes after $oldRev up to and including $newRev removed
352            if ( WikiPage::hasDifferencesOutsideMainSlot( $newRevRecord, $oldRevRecord ) ) {
353                return 'review_cannot_undo';
354            }
355            $undoHandler = $services->getContentHandlerFactory()
356                ->getContentHandler(
357                    $newRevRecord->getSlot( SlotRecord::MAIN )->getModel()
358                );
359            $currentContent = $article->getRevisionRecord()->getContent( SlotRecord::MAIN );
360            $undoContent = $newRevRecord->getContent( SlotRecord::MAIN );
361            $undoAfterContent = $oldRevRecord->getContent( SlotRecord::MAIN );
362            if ( !$currentContent || !$undoContent || !$undoAfterContent ) {
363                return 'review_cannot_undo';
364            }
365            $new_content = $undoHandler->getUndoContent(
366                $currentContent,
367                $undoContent,
368                $undoAfterContent,
369                $newRevRecord->isCurrent()
370            );
371            if ( $new_content === false ) {
372                return 'review_cannot_undo';
373            }
374
375            $baseRevId = $newRevRecord->isCurrent() ? $oldRevRecord->getId() : 0;
376
377            $comment = $this->getComment();
378
379            // Actually make the edit...
380            // Note: this should be changed to use the $undidRevId parameter so that the
381            // edit is properly marked as an undo. Do this only after T153570 is merged
382            // into Echo, otherwise we would get duplicate revert notifications.
383            $editStatus = $article->doUserEditContent(
384                $new_content,
385                $user,
386                $comment,
387                0, // flags
388                $baseRevId
389            );
390
391            $status = $editStatus->isOK() ? true : 'review_cannot_undo';
392
393            // Notify Echo about the revert.
394            // This is due to the lack of appropriate EditResult handling in Echo, in the
395            // future, when T153570 is merged, this entire code block should be removed.
396            if ( $status === true &&
397                // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
398                $editStatus->value['revision-record'] &&
399                ExtensionRegistry::getInstance()->isLoaded( 'Echo' )
400            ) {
401                $affectedRevisions = []; // revid -> userid
402                $revQuery = $revStore->getQueryInfo();
403                $dbr = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase();
404
405                $revisions = $dbr->newSelectQueryBuilder()
406                    ->select( [ 'rev_id', 'rev_user' => $revQuery['fields']['rev_user'] ] )
407                    ->tables( $revQuery['tables'] )
408                    ->where( [
409                        $dbr->expr( 'rev_id', '<=', $newRevRecord->getId() ),
410                        $dbr->expr( 'rev_timestamp', '<=', $dbr->timestamp( $newRevRecord->getTimestamp() ) ),
411                        $dbr->expr( 'rev_id', '>', $oldRevRecord->getId() ),
412                        $dbr->expr( 'rev_timestamp', '>', $dbr->timestamp( $oldRevRecord->getTimestamp() ) ),
413                        'rev_page' => $article->getId(),
414                    ] )
415                    ->joinConds( $revQuery['joins'] )
416                    ->caller( __METHOD__ )
417                    ->fetchResultSet();
418                foreach ( $revisions as $row ) {
419                    $affectedRevisions[$row->rev_id] = $row->rev_user;
420                }
421
422                Event::create( [
423                    'type' => 'reverted',
424                    'title' => $this->title,
425                    'extra' => [
426                        // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
427                        'revid' => $editStatus->value['revision-record']->getId(),
428                        'reverted-users-ids' => array_values( $affectedRevisions ),
429                        'reverted-revision-ids' => array_keys( $affectedRevisions ),
430                        'method' => 'flaggedrevs-reject',
431                    ],
432                    'agent' => $user,
433                ] );
434            }
435        } else {
436            return 'review_param_missing';
437        }
438        # Watch page if set to do so
439        if ( $status === true ) {
440            $userOptionsLookup = $services->getUserOptionsLookup();
441            $watchlistManager = $services->getWatchlistManager();
442            if ( $userOptionsLookup->getOption( $user, 'flaggedrevswatch' ) &&
443                !$watchlistManager->isWatched( $user, $this->title ) ) {
444                $watchlistManager->addWatch( $user, $this->title );
445            }
446        }
447
448        ( new FlaggedRevsHookRunner( $services->getHookContainer() ) )->onFlaggedRevsRevisionReviewFormAfterDoSubmit(
449            $this,
450            $status
451        );
452
453        return $status;
454    }
455
456    /**
457     * Adds or updates the flagged revision table for this page/id set
458     * @param RevisionRecord $revRecord The revision to be accepted
459     * @param FlaggedRevision|null $oldFrev Currently accepted version of $rev or null
460     */
461    private function approveRevision(
462        RevisionRecord $revRecord,
463        ?FlaggedRevision $oldFrev = null
464    ) {
465        # Revision rating flags
466        $flags = $this->getTags();
467        # Get current stable version ID (for logging)
468        $oldSv = FlaggedRevision::newFromStable( $this->title, IDBAccessObject::READ_LATEST );
469
470        # Is this a duplicate review?
471        if ( $oldFrev && $oldFrev->getTags() == $flags ) {
472            return; // don't record if the same
473        }
474
475        # The new review entry...
476        $flaggedRevision = new FlaggedRevision( [
477            'revrecord'         => $revRecord,
478            'user_id'           => $this->user->getId(),
479            'timestamp'         => wfTimestampNow(),
480            'tags'              => $flags,
481            'flags'             => ''
482        ] );
483        # Delete the old review entry if it exists...
484        if ( $oldFrev ) {
485            $oldFrev->delete();
486        }
487        # Insert the new review entry...
488        $status = $flaggedRevision->insert();
489        if ( $status !== true ) {
490            throw new UnexpectedValueException(
491                'Flagged revision with ID ' .
492                (string)$revRecord->getId() .
493                ' exists with unexpected fr_page_id, error: ' . $status
494            );
495        }
496
497        $flaggedRevision->approveRevertedTagUpdate();
498
499        # Update the article review log...
500        $oldSvId = $oldSv ? $oldSv->getRevId() : 0;
501        FlaggedRevsLog::updateReviewLog( $this->title, $this->getTags(),
502            $this->comment, $this->oldid, $oldSvId, true, $this->user );
503
504        # Get the new stable version as of now
505        $sv = FlaggedRevision::determineStable( $this->title );
506        # Update recent changes...
507        self::updateRecentChanges( $revRecord, 'patrol', $sv );
508        # Update page and tracking tables and clear cache
509        $changed = FlaggedRevs::stableVersionUpdates( $this->title, $sv, $oldSv );
510        if ( $changed ) {
511            FlaggedRevs::updateHtmlCaches( $this->title ); // purge pages that use this page
512        }
513
514        # Caller may want to get the change time
515        $this->newLastChangeTime = $flaggedRevision->getTimestamp();
516    }
517
518    /**
519     * @param FlaggedRevision $frev
520     * Removes flagged revision data for this page/id set
521     */
522    private function unapproveRevision( FlaggedRevision $frev ) {
523        # Get current stable version ID (for logging)
524        $oldSv = FlaggedRevision::newFromStable( $this->title, IDBAccessObject::READ_LATEST );
525
526        # Delete from flaggedrevs table
527        $frev->delete();
528
529        # Get the new stable version as of now
530        $sv = FlaggedRevision::determineStable( $this->title );
531
532        # Update the article review log
533        $svId = $sv ? $sv->getRevId() : 0;
534        FlaggedRevsLog::updateReviewLog( $this->title, $this->getTags(),
535            $this->comment, $this->oldid, $svId, false, $this->user );
536
537        # Update recent changes
538        self::updateRecentChanges( $frev->getRevisionRecord(), 'unpatrol', $sv );
539        # Update page and tracking tables and clear cache
540        $changed = FlaggedRevs::stableVersionUpdates( $this->title, $sv, $oldSv );
541        if ( $changed ) {
542            FlaggedRevs::updateHtmlCaches( $this->title ); // purge pages that use this page
543        }
544
545        # Caller may want to get the change time
546        $this->newLastChangeTime = '';
547    }
548
549    /**
550     * Get a validation key from template versioning metadata
551     * @param int $revisionId
552     * @param string $sessKey Session key
553     * @return string
554     */
555    public static function validationKey( $revisionId, $sessKey ) {
556        global $wgSecretKey;
557        $key = md5( $wgSecretKey );
558        $keyBits = $key[3] . $key[9] . $key[13] . $key[19] . $key[26];
559        return md5( $revisionId . $sessKey . $keyBits );
560    }
561
562    /**
563     * Update rc_patrolled fields in recent changes after (un)accepting a rev.
564     * This maintains the patrolled <=> reviewed relationship for reviewable namespaces.
565     *
566     * RecentChange should only be passed in when an RC item is saved.
567     *
568     * @param RevisionRecord|RecentChange $rev
569     * @param string $patrol "patrol" or "unpatrol"
570     * @param FlaggedRevision|null $srev The new stable version
571     * @return void
572     */
573    public static function updateRecentChanges( $rev, $patrol, $srev ) {
574        if ( $rev instanceof RecentChange ) {
575            $pageId = $rev->getAttribute( 'rc_cur_id' );
576        } else {
577            $pageId = $rev->getPageId();
578        }
579        $sTimestamp = $srev ? $srev->getRevTimestamp() : null;
580
581        $dbw = MediaWikiServices::getInstance()->getConnectionProvider()->getPrimaryDatabase();
582
583        $limit = 100; // sanity limit to avoid replica lag (most useful when FR is first enabled)
584        $conds = [ 'rc_cur_id' => $pageId ];
585
586        $newPatrolState = null; // set rc_patrolled to this value
587        # If we accepted this rev, then mark prior revs as patrolled...
588        if ( $patrol === 'patrol' ) {
589            if ( $sTimestamp ) { // sanity check; should always be set
590                $conds[] = $dbw->expr( 'rc_timestamp', '<=', $dbw->timestamp( $sTimestamp ) );
591                $newPatrolState = 1;
592            }
593        # If we un-accepted this rev, then mark now-pending revs as unpatrolled...
594        } elseif ( $patrol === 'unpatrol' ) {
595            if ( $sTimestamp ) {
596                $conds[] = $dbw->expr( 'rc_timestamp', '>', $dbw->timestamp( $sTimestamp ) );
597            }
598            $newPatrolState = 0;
599        }
600
601        if ( $newPatrolState === null ) {
602            return; // leave alone
603        }
604
605        // Only update rows that need it
606        $conds['rc_patrolled'] = $newPatrolState ? 0 : 1;
607        // SELECT and update by PK to avoid lag
608        $rcIds = $dbw->newSelectQueryBuilder()
609            ->select( 'rc_id' )
610            ->from( 'recentchanges' )
611            ->where( $conds )
612            ->limit( $limit )
613            ->caller( __METHOD__ )
614            ->fetchFieldValues();
615        if ( $rcIds ) {
616            $dbw->newUpdateQueryBuilder()
617                ->update( 'recentchanges' )
618                ->set( [ 'rc_patrolled' => $newPatrolState ] )
619                ->where( [ 'rc_id' => $rcIds ] )
620                ->caller( __METHOD__ )
621                ->execute();
622        }
623    }
624}