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