Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
77.44% |
103 / 133 |
|
60.00% |
3 / 5 |
CRAP | |
0.00% |
0 / 1 |
AutoModeratorFetchRevScoreJob | |
77.44% |
103 / 133 |
|
60.00% |
3 / 5 |
43.75 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
run | |
69.47% |
66 / 95 |
|
0.00% |
0 / 1 |
31.38 | |||
getLiftWingRevScore | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
2 | |||
getOresRevScore | |
96.15% |
25 / 26 |
|
0.00% |
0 / 1 |
6 | |||
setAllowRetries | |
100.00% |
1 / 1 |
|
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 | |
17 | namespace AutoModerator\Services; |
18 | |
19 | use AutoModerator\AutoModeratorRevisionStore; |
20 | use AutoModerator\AutoModeratorServices; |
21 | use AutoModerator\OresScoreFetcher; |
22 | use AutoModerator\RevisionCheck; |
23 | use AutoModerator\Util; |
24 | use MediaWiki\Config\Config; |
25 | use MediaWiki\Config\ServiceOptions; |
26 | use MediaWiki\JobQueue\Job; |
27 | use MediaWiki\Logger\LoggerFactory; |
28 | use MediaWiki\MediaWikiServices; |
29 | use MediaWiki\Registration\ExtensionRegistry; |
30 | use MediaWiki\Title\Title; |
31 | use Psr\Log\LoggerInterface; |
32 | use RuntimeException; |
33 | use Wikimedia\Rdbms\IConnectionProvider; |
34 | |
35 | class 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 | } |