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