Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.81% covered (success)
93.81%
91 / 97
75.00% covered (warning)
75.00%
9 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
EditResultBuilder
93.81% covered (success)
93.81%
91 / 97
75.00% covered (warning)
75.00%
9 / 12
40.38
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 buildEditResult
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
2
 setRevisionRecord
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setIsNew
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 markAsRevert
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
5
 setOriginalRevision
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 detectManualRevert
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
7.01
 guessOriginalRevisionId
75.00% covered (warning)
75.00%
9 / 12
0.00% covered (danger)
0.00%
0 / 1
9.00
 getOriginalRevision
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
3.33
 isExactRevert
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 isNullEdit
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 getRevertTags
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6
7namespace MediaWiki\Storage;
8
9use MediaWiki\Config\ServiceOptions;
10use MediaWiki\MainConfigNames;
11use MediaWiki\Revision\RevisionRecord;
12use MediaWiki\Revision\RevisionStore;
13use Wikimedia\Assert\Assert;
14
15/**
16 * Builder class for the EditResult object.
17 *
18 * @internal Only for use by PageUpdater
19 * @since 1.35
20 * @author Ostrzyciel
21 */
22class EditResultBuilder {
23
24    public const CONSTRUCTOR_OPTIONS = [
25        MainConfigNames::ManualRevertSearchRadius,
26    ];
27
28    /**
29     * A mapping from EditResult's revert methods to relevant change tags.
30     * For use by getRevertTags()
31     */
32    private const REVERT_METHOD_TO_CHANGE_TAG = [
33        EditResult::REVERT_UNDO => 'mw-undo',
34        EditResult::REVERT_ROLLBACK => 'mw-rollback',
35        EditResult::REVERT_MANUAL => 'mw-manual-revert'
36    ];
37
38    /** @var RevisionRecord|null */
39    private $revisionRecord = null;
40
41    /** @var bool */
42    private $isNew = false;
43
44    /** @var int|false */
45    private $originalRevisionId = false;
46
47    /** @var RevisionRecord|null */
48    private $originalRevision = null;
49
50    /** @var int|null */
51    private $revertMethod = null;
52
53    /** @var int|null */
54    private $newestRevertedRevId = null;
55
56    /** @var int|null */
57    private $oldestRevertedRevId = null;
58
59    /** @var int|null */
60    private $revertAfterRevId = null;
61
62    /** @var RevisionStore */
63    private $revisionStore;
64
65    /** @var string[] */
66    private $softwareTags;
67
68    /** @var ServiceOptions */
69    private $options;
70
71    /**
72     * @param RevisionStore $revisionStore
73     * @param string[] $softwareTags Array of currently enabled software change tags. Can be
74     *        obtained from ChangeTagsStore->getSoftwareTags()
75     * @param ServiceOptions $options Options for this instance.
76     */
77    public function __construct(
78        RevisionStore $revisionStore,
79        array $softwareTags,
80        ServiceOptions $options
81    ) {
82        $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
83
84        $this->revisionStore = $revisionStore;
85        $this->softwareTags = $softwareTags;
86        $this->options = $options;
87    }
88
89    public function buildEditResult(): EditResult {
90        if ( $this->revisionRecord === null ) {
91            throw new PageUpdateException(
92                'Revision was not set prior to building an EditResult'
93            );
94        }
95
96        // If we don't know the original revision ID, but know which one was undone, try to find out
97        $this->guessOriginalRevisionId();
98
99        // do a last-minute check if this was a manual revert
100        $this->detectManualRevert();
101
102        return new EditResult(
103            $this->isNew,
104            $this->originalRevisionId,
105            $this->revertMethod,
106            $this->oldestRevertedRevId,
107            $this->newestRevertedRevId,
108            $this->isExactRevert(),
109            $this->isNullEdit(),
110            $this->getRevertTags()
111        );
112    }
113
114    /**
115     * Set the revision associated with this edit.
116     * Should only be called by PageUpdater when saving an edit.
117     */
118    public function setRevisionRecord( RevisionRecord $revisionRecord ) {
119        $this->revisionRecord = $revisionRecord;
120    }
121
122    /**
123     * Set whether the edit created a new page.
124     * Should only be called by PageUpdater when saving an edit.
125     */
126    public function setIsNew( bool $isNew ) {
127        $this->isNew = $isNew;
128    }
129
130    /**
131     * Marks this edit as a revert and applies relevant information.
132     *
133     * @param int $revertMethod The method used to make the revert:
134     *   REVERT_UNDO, REVERT_ROLLBACK or REVERT_MANUAL
135     * @param int $newestRevertedRevId the revision ID of the latest reverted revision.
136     * @param int|null $revertAfterRevId the revision ID after which revisions
137     *   are being reverted. Defaults to the revision before the $newestRevertedRevId.
138     */
139    public function markAsRevert(
140        int $revertMethod,
141        int $newestRevertedRevId,
142        ?int $revertAfterRevId = null
143    ) {
144        Assert::parameter(
145            in_array(
146                $revertMethod,
147                [ EditResult::REVERT_UNDO, EditResult::REVERT_ROLLBACK, EditResult::REVERT_MANUAL ]
148            ),
149            '$revertMethod',
150            'must be one of REVERT_UNDO, REVERT_ROLLBACK, REVERT_MANUAL'
151        );
152        $this->revertAfterRevId = $revertAfterRevId;
153
154        if ( $newestRevertedRevId ) {
155            $this->revertMethod = $revertMethod;
156            $this->newestRevertedRevId = $newestRevertedRevId;
157            $revertAfterRevision = $revertAfterRevId ?
158                $this->revisionStore->getRevisionById( $revertAfterRevId ) :
159                null;
160            $oldestRevertedRev = $revertAfterRevision ?
161                $this->revisionStore->getNextRevision( $revertAfterRevision ) : null;
162            if ( $oldestRevertedRev ) {
163                $this->oldestRevertedRevId = $oldestRevertedRev->getId();
164            } else {
165                // Can't find the oldest reverted revision.
166                // Oh well, just mark the one we know was undone.
167                $this->oldestRevertedRevId = $this->newestRevertedRevId;
168            }
169        }
170    }
171
172    /**
173     * @param RevisionRecord|int|false|null $originalRevision
174     *   RevisionRecord or revision ID for the original revision.
175     *   False or null to unset.
176     */
177    public function setOriginalRevision( $originalRevision ) {
178        if ( $originalRevision instanceof RevisionRecord ) {
179            $this->originalRevision = $originalRevision;
180            $this->originalRevisionId = $originalRevision->getId();
181        } else {
182            $this->originalRevisionId = $originalRevision ?? false;
183            $this->originalRevision = null; // Will be lazy-loaded.
184        }
185    }
186
187    /**
188     * If this edit was not already marked as a revert using EditResultBuilder::markAsRevert(),
189     * tries to establish whether this was a manual revert, i.e. someone restored the page to
190     * an exact previous state manually.
191     *
192     * If successful, mutates the builder accordingly.
193     */
194    private function detectManualRevert() {
195        $searchRadius = $this->options->get( MainConfigNames::ManualRevertSearchRadius );
196        if ( !$searchRadius ||
197            // we already marked this as a revert
198            $this->revertMethod !== null ||
199            // it's a null edit, nothing was reverted
200            $this->isNullEdit() ||
201            // we wouldn't be able to figure out what was the newest reverted edit
202            // this also discards new pages
203            !$this->revisionRecord->getParentId()
204        ) {
205            return;
206        }
207
208        $revertedToRev = $this->revisionStore->findIdenticalRevision( $this->revisionRecord, $searchRadius );
209        if ( !$revertedToRev ) {
210            return;
211        }
212        $oldestReverted = $this->revisionStore->getNextRevision( $revertedToRev );
213        if ( !$oldestReverted ) {
214            return;
215        }
216
217        $this->setOriginalRevision( $revertedToRev );
218        $this->revertMethod = EditResult::REVERT_MANUAL;
219        $this->oldestRevertedRevId = $oldestReverted->getId();
220        $this->newestRevertedRevId = $this->revisionRecord->getParentId();
221        $this->revertAfterRevId = $revertedToRev->getId();
222    }
223
224    /**
225     * In case we have not got the original revision ID, try to guess.
226     */
227    private function guessOriginalRevisionId() {
228        if ( !$this->originalRevisionId ) {
229            if ( $this->revertAfterRevId ) {
230                $this->setOriginalRevision( $this->revertAfterRevId );
231            } elseif ( $this->newestRevertedRevId ) {
232                // Try finding the original revision ID by assuming it's the one before the edit
233                // that is being reverted.
234                $undidRevision = $this->revisionStore->getRevisionById( $this->newestRevertedRevId );
235                if ( $undidRevision ) {
236                    $originalRevision = $this->revisionStore->getPreviousRevision( $undidRevision );
237                    if ( $originalRevision ) {
238                        $this->setOriginalRevision( $originalRevision );
239                    }
240                }
241            }
242        }
243
244        // Make sure original revision's content is the same as
245        // the new content and save the original revision ID.
246        if ( $this->getOriginalRevision() &&
247            !$this->getOriginalRevision()->hasSameContent( $this->revisionRecord )
248        ) {
249            $this->setOriginalRevision( false );
250        }
251    }
252
253    /**
254     * Returns the revision that is being repeated or restored.
255     * Returns null if not set for this edit.
256     *
257     * @return RevisionRecord|null
258     */
259    private function getOriginalRevision(): ?RevisionRecord {
260        if ( $this->originalRevision ) {
261            return $this->originalRevision;
262        }
263        if ( !$this->originalRevisionId ) {
264            return null;
265        }
266
267        $this->originalRevision = $this->revisionStore->getRevisionById( $this->originalRevisionId );
268        return $this->originalRevision;
269    }
270
271    /**
272     * Whether the edit was an exact revert, i.e. the contents of the revert
273     * revision and restored revision match
274     */
275    private function isExactRevert(): bool {
276        if ( $this->isNew || $this->oldestRevertedRevId === null ) {
277            return false;
278        }
279
280        $originalRevision = $this->getOriginalRevision();
281        if ( !$originalRevision ) {
282            // we can't find the original revision for some reason, better return false
283            return false;
284        }
285
286        return $this->revisionRecord->hasSameContent( $originalRevision );
287    }
288
289    /**
290     * An edit is a null edit if the original revision is equal to the parent revision.
291     */
292    private function isNullEdit(): bool {
293        if ( $this->isNew ) {
294            return false;
295        }
296
297        return $this->getOriginalRevision() &&
298            $this->originalRevisionId === $this->revisionRecord->getParentId();
299    }
300
301    /**
302     * Returns an array of revert-related tags that will be applied automatically to this edit.
303     *
304     * @return string[]
305     */
306    private function getRevertTags(): array {
307        if ( $this->revertMethod !== null ) {
308            $revertTag = self::REVERT_METHOD_TO_CHANGE_TAG[$this->revertMethod];
309            if ( in_array( $revertTag, $this->softwareTags ) ) {
310                return [ $revertTag ];
311            }
312        }
313        return [];
314    }
315}