Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
77.44% covered (warning)
77.44%
103 / 133
60.00% covered (warning)
60.00%
3 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
AutoModeratorFetchRevScoreJob
77.44% covered (warning)
77.44%
103 / 133
60.00% covered (warning)
60.00%
3 / 5
43.75
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
 run
69.47% covered (warning)
69.47%
66 / 95
0.00% covered (danger)
0.00%
0 / 1
31.38
 getLiftWingRevScore
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 getOresRevScore
96.15% covered (success)
96.15%
25 / 26
0.00% covered (danger)
0.00%
0 / 1
6
 setAllowRetries
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 allowRetries
n/a
0 / 0
n/a
0 / 0
1
 ignoreDuplicates
n/a
0 / 0
n/a
0 / 0
1
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 3 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
14 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
15 */
16
17namespace AutoModerator\Services;
18
19use AutoModerator\AutoModeratorRevisionStore;
20use AutoModerator\AutoModeratorServices;
21use AutoModerator\OresScoreFetcher;
22use AutoModerator\RevisionCheck;
23use AutoModerator\Util;
24use MediaWiki\Config\Config;
25use MediaWiki\Config\ServiceOptions;
26use MediaWiki\JobQueue\Job;
27use MediaWiki\Logger\LoggerFactory;
28use MediaWiki\MediaWikiServices;
29use MediaWiki\Registration\ExtensionRegistry;
30use MediaWiki\Title\Title;
31use Psr\Log\LoggerInterface;
32use RuntimeException;
33use Wikimedia\Rdbms\IConnectionProvider;
34
35class AutoModeratorFetchRevScoreJob extends Job {
36
37    /**
38     * @var int
39     */
40    private $wikiPageId;
41
42    /**
43     * @var int
44     */
45    private $revId;
46
47    /**
48     * @var bool
49     */
50    private bool $isRetryable = true;
51
52    /**
53     * @var ?array
54     */
55    private $scores;
56
57    /**
58     * @param Title $title
59     * @param array $params
60     *    - 'wikiPageId': (int)
61     *    - 'revId': (int)
62     *    - 'originalRevId': (int|false)
63     *    - 'userId': (int)
64     *    - 'userName': (string)
65     *    - 'tags': (string[])
66     *    - 'scores': (?array)
67     */
68    public function __construct( Title $title, array $params ) {
69        parent::__construct( 'AutoModeratorFetchRevScoreJob', $title, $params );
70        $this->wikiPageId = $params[ 'wikiPageId' ];
71        $this->revId = $params[ 'revId' ];
72        $this->scores = $params[ 'scores' ];
73    }
74
75    public function run(): bool {
76        $services = MediaWikiServices::getInstance();
77        $autoModeratorServices = AutoModeratorServices::wrap( $services );
78        $wikiPageFactory = $services->getWikiPageFactory();
79        $revisionStore = $services->getRevisionStore();
80        $userGroupManager = $services->getUserGroupManager();
81        $config = $services->getMainConfig();
82        $wikiConfig = $autoModeratorServices->getAutoModeratorWikiConfig();
83        $connectionProvider = $services->getConnectionProvider();
84        $autoModeratorUser = Util::getAutoModeratorUser( $config, $userGroupManager );
85        $wikiId = Util::getWikiID( $config );
86        $logger = LoggerFactory::getInstance( 'AutoModerator' );
87        $userFactory = $services->getUserFactory();
88
89        $rev = $revisionStore->getRevisionById( $this->revId );
90        if ( $rev === null ) {
91            $error = "rev {$this->revId} not found";
92            $logger->debug( __METHOD__ . " - " . $error );
93            $this->setLastError( $error );
94            $this->setAllowRetries( true );
95            return false;
96        }
97        try {
98            $user = $userFactory->newFromAnyId(
99                $this->params['userId'],
100                $this->params['userName']
101            );
102            $maxReverts = $wikiConfig->get( 'AutoModeratorUserRevertsPerPage' );
103            if ( $wikiConfig->get( 'AutoModeratorEnableUserRevertsPerPage' ) && $maxReverts ) {
104                $autoModeratorRevisionStore = new AutoModeratorRevisionStore(
105                    $connectionProvider->getReplicaDatabase(),
106                    $user,
107                    $autoModeratorUser,
108                    $this->wikiPageId,
109                    $revisionStore,
110                    $maxReverts
111                );
112                if ( $autoModeratorRevisionStore->hasReachedMaxRevertsForUser() ) {
113                    $logger->debug( __METHOD__ . " - AutoModerator has reached the maximum reverts for this user" );
114                    return true;
115                }
116            }
117            $response = false;
118            // Model name defaults to language-agnostic model name
119            $revertRiskModelName = Util::getRevertRiskModel( $config, $wikiConfig );
120            if ( ExtensionRegistry::getInstance()->isLoaded( 'ORES' ) ) {
121                $oresModels = $config->get( 'OresModels' );
122
123                if ( array_key_exists( $revertRiskModelName, $oresModels ) &&
124                    $oresModels[ $revertRiskModelName ][ 'enabled' ] ) {
125                    // ORES is loaded and the model is enabled, fetching the score from there
126                    $response = $this->getOresRevScore( $connectionProvider, $revertRiskModelName, $wikiId, $logger );
127                }
128            }
129
130            if ( !$response ) {
131                // ORES is not loaded, or a score couldn't be retrieved from the extension
132                $response = $this->getLiftWingRevScore( $config, $wikiConfig );
133            }
134            if ( !$response ) {
135                $error = "score could not be retrieved for {$this->revId}";
136                $logger->debug( __METHOD__ . " - " . $error );
137                $this->setLastError( $error );
138                $this->setAllowRetries( true );
139                return false;
140            }
141            $revisionCheck = new RevisionCheck(
142                $wikiConfig,
143                $config,
144                new AutoModeratorRollback(
145                    new ServiceOptions( AutoModeratorRollback::CONSTRUCTOR_OPTIONS, $config ),
146                    $services->getDBLoadBalancerFactory(),
147                    $revisionStore,
148                    $services->getTitleFormatter(),
149                    $services->getHookContainer(),
150                    $wikiPageFactory,
151                    $services->getActorMigration(),
152                    $services->getActorNormalization(),
153                    $wikiPageFactory->newFromID( $this->wikiPageId ),
154                    $autoModeratorUser->getUser(),
155                    $user,
156                    $config,
157                    $wikiConfig
158                ),
159                true
160            );
161            $reverted = $revisionCheck->maybeRollback( $response, $revertRiskModelName );
162
163        } catch ( RuntimeException $exception ) {
164            $logger->debug( __METHOD__ . " - " . $exception->getMessage() );
165            $this->setLastError( $exception->getMessage() );
166            return false;
167        }
168        // Revision reverted
169        if ( array_key_exists( '1', $reverted ) && $reverted['1'] === 'success' ) {
170            return true;
171        }
172        // Revert attempted but failed
173        if ( array_key_exists( '0', $reverted ) && $reverted['0'] === 'failure' ) {
174            $this->setLastError( 'Revision ' . $this->revId . ' requires a manual revert.' );
175            $this->setAllowRetries( false );
176            return false;
177        }
178        // Revision passed check; noop.
179        if ( array_key_exists( '0', $reverted ) && $reverted['0'] === 'Not reverted' ) {
180            return true;
181        }
182        // Revision unable to be reverted due to an edit conflict or race condition in the job queue
183        if ( array_key_exists( '0', $reverted ) && $reverted['0'] === 'success' ) {
184            $logger->debug( __METHOD__ . " - " . $reverted['0'] );
185            $this->setAllowRetries( false );
186            return true;
187        }
188        // Revert attempted but failed to save revision record due to unknown reason
189        if ( array_key_exists( '0', $reverted ) ) {
190            $logger->debug( __METHOD__ . " - " . $reverted['0'] );
191            $this->setLastError( $reverted['0'] );
192            $this->setAllowRetries( true );
193            return false;
194        }
195
196        $logger->debug( __METHOD__ . " - Default false" );
197        return false;
198    }
199
200    /**
201     * Obtains a score from LiftWing API
202     * @param Config $config
203     * @param Config $wikiConfig
204     * @return array|false
205     */
206    private function getLiftWingRevScore( Config $config, Config $wikiConfig ) {
207        $liftWingClient = Util::initializeLiftWingClient( $config, $wikiConfig );
208        $response = $liftWingClient->get( $this->revId );
209        $this->setAllowRetries( $response[ 'allowRetries' ] ?? true );
210        if ( isset( $response['errorMessage'] ) ) {
211            $this->setLastError( $response['errorMessage'] );
212            return false;
213        }
214        return $response;
215    }
216
217    /**
218     * Obtains a score from ORES classification table
219     * @param IConnectionProvider $connectionProvider
220     * @param string $revertRiskModelName
221     * @param string $wikiId
222     * @param LoggerInterface $logger
223     * @return array|false
224     */
225    private function getOresRevScore( IConnectionProvider $connectionProvider, string $revertRiskModelName,
226        string $wikiId, LoggerInterface $logger ) {
227        if ( $this->scores ) {
228            foreach ( $this->scores as $rev_id => $score ) {
229                if ( $rev_id === $this->revId && array_key_exists( $revertRiskModelName, $score ) ) {
230                    return [
231                        'output' => [
232                            'probabilities' => [
233                                'true' => $score[ $revertRiskModelName ][ 'score' ][ 'probability' ][ 'true' ]
234                            ]
235                        ]
236                    ];
237                }
238            }
239        }
240        // If there where no score returns, we should try to fetch the score from the database
241        $oresScoreFetcher = new OresScoreFetcher( $connectionProvider );
242        $logger->debug( 'Score was not found in scores hook array; getting it from ORES DB' );
243        $oresDbRow = $oresScoreFetcher->getOresScore( $this->revId );
244        if ( !$oresDbRow ) {
245            // Database query did not find revision score, returning false
246            return false;
247        }
248        // Creating a response that is similar to the one LiftWing API returns
249        // Omitting some unused information
250        return [
251            'model_name' => $oresDbRow->oresm_name,
252            'model_version' => $oresDbRow->oresm_version,
253            'wiki_db' => $wikiId,
254            'revision_id' => $this->revId,
255            'output' => [
256                'probabilities' => [
257                    'true' => $oresDbRow->oresc_probability
258                ],
259            ],
260        ];
261    }
262
263    private function setAllowRetries( bool $isRetryable ) {
264        $this->isRetryable = $isRetryable;
265    }
266
267    /**
268     * @inheritDoc
269     * @codeCoverageIgnore
270     */
271    public function allowRetries(): bool {
272        return $this->isRetryable;
273    }
274
275    /**
276     * @inheritDoc
277     * @codeCoverageIgnore
278     */
279    public function ignoreDuplicates(): bool {
280        return true;
281    }
282}