Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
58.85% covered (warning)
58.85%
133 / 226
66.67% covered (warning)
66.67%
2 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
PagePostSaveHandler
58.85% covered (warning)
58.85%
133 / 226
66.67% covered (warning)
66.67%
2 / 3
151.49
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 onRecentChange_save
58.30% covered (warning)
58.30%
130 / 223
0.00% covered (danger)
0.00%
0 / 1
142.74
 roundTripJson
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3/**
4 * WikiLambda handler for hooks which react to page editing
5 *
6 * @file
7 * @ingroup Extensions
8 * @copyright 2020– Abstract Wikipedia team; see AUTHORS.txt
9 * @license MIT
10 */
11
12namespace MediaWiki\Extension\WikiLambda\HookHandler;
13
14use MediaWiki\Config\Config;
15use MediaWiki\Extension\WikiLambda\Diff\ZObjectDiffer;
16use MediaWiki\Extension\WikiLambda\Jobs\WikifunctionsClientFanOutQueueJob;
17use MediaWiki\Extension\WikiLambda\Registry\ZTypeRegistry;
18use MediaWiki\Extension\WikiLambda\ZErrorException;
19use MediaWiki\Extension\WikiLambda\ZObjectContent;
20use MediaWiki\Extension\WikiLambda\ZObjectStore;
21use MediaWiki\Logger\LoggerFactory;
22use MediaWiki\MediaWikiServices;
23use MediaWiki\RecentChanges\RecentChange;
24use MediaWiki\Title\Title;
25use Psr\Log\LoggerInterface;
26use Wikimedia\Rdbms\IConnectionProvider;
27use Wikimedia\Rdbms\IReadableDatabase;
28
29class PagePostSaveHandler implements
30    \MediaWiki\RecentChanges\Hook\RecentChange_saveHook
31{
32    private IReadableDatabase $dbr;
33    private LoggerInterface $logger;
34
35    public function __construct(
36        IConnectionProvider $dbProvider,
37        private readonly Config $config,
38        private readonly ZObjectStore $zObjectStore
39
40    ) {
41            $this->dbr = $dbProvider->getReplicaDatabase();
42            $this->logger = LoggerFactory::getInstance( 'WikiLambda' );
43    }
44
45    /**
46     * @see https://www.mediawiki.org/wiki/Manual:Hooks/RecentChange_save
47     *
48     * @param RecentChange $recentChange
49     * @return bool|void
50     */
51    public function onRecentChange_save( $recentChange ) {
52        // We use this on the repo-mode wiki to create a job that *might* trigger updates to client wikis
53
54        $targetPage = $recentChange->getPage();
55        if (
56            // We're not in repo-mode
57            !$this->config->get( 'WikiLambdaEnableRepoMode' ) ||
58            // We're on a page that's not in the main namespace
59            $targetPage->getNamespace() !== NS_MAIN
60        ) {
61            // Nothing for us to do.
62            return;
63        }
64
65        // Start collecting the structured data about the edit that we'll send to client wikis
66        $changeData = [];
67
68        $changeComment = $recentChange->getAttribute( 'rc_comment_text' );
69
70        // If this is a logged action, we only care the edge case of deletions; other kinds, like moves, are irrelevant
71        $logType = $recentChange->getAttribute( 'rc_log_type' );
72        if ( $logType !== null ) {
73            if ( $logType === 'delete' ) {
74                // … and specifically, full page deletions and restorations; revision deletions don't matter, as they
75                // won't ever affect the current revision (so a previous RC entry for the creation of the newer rev will
76                // have been sent to the client wikis).
77                $logAction = $recentChange->getAttribute( 'rc_log_action' );
78                if ( $logAction !== 'restore' && $logAction !== 'delete' ) {
79                    return;
80                }
81                $changeData['action'] = $logAction;
82
83                // We need to look up the comment made for the log entry in this case.
84                $changeId = $recentChange->getAttribute( 'rc_logid' );
85
86                // Ideally we'd get this from the LogFormatterFactory's method, but it appears broken for RC entries:
87                // $changeComment = $this->logFormatterFactory->newFromRow( $recentChange )->getComment();
88
89                // This walks rc_logid => log_id; log_comment_id => comment_id; then returns comment_text
90                $changeComment = $this->dbr->newSelectQueryBuilder()
91                    ->select( [ 'comment_text' ] )
92                    ->from( 'recentchanges' )
93                    ->where( [ 'log_id' => $changeId ] )
94                    ->join( 'logging', null, [ 'rc_logid = log_id' ] )
95                    ->join( 'comment', null, [ 'log_comment_id = comment_id' ] )
96                    ->caller( __METHOD__ )
97                    ->fetchField();
98            }
99        }
100
101        $targetTitle = Title::castFromPageReference( $targetPage );
102        if ( $targetTitle === null ) {
103            // This isn't a valid title, so we don't care.
104            return;
105        }
106
107        $newId = $recentChange->getAttribute( 'rc_this_oldid' );
108        $changeData['newId'] = $newId;
109        $newTargetZObject = $this->zObjectStore->fetchZObjectByTitle( $targetTitle, $newId );
110        if ( !$newTargetZObject || !( $newTargetZObject instanceof ZObjectContent ) ) {
111            // This isn't a ZObject, so we don't care.
112            // var_dump( $newTargetZObject );
113            return;
114        }
115
116        try {
117            $changedObject = $newTargetZObject->getZid();
118            $changeData['target'] = $changedObject;
119            $changeData['type'] = $newTargetZObject->getZType();
120        } catch ( ZErrorException $ze ) {
121            // (T406708): Something's gone wrong; log and exit.
122            $this->logger->error(
123                __METHOD__ . ': Failed to get ZID/Type for {obj} revision {rev}: {error}',
124                [
125                    'obj' => $targetTitle->getDBkey(),
126                    'rev' => $newId,
127                    'exception' => $ze,
128                    'message' => $ze->getMessage(),
129                    'errortype' => $ze->getZErrorType()
130                ]
131            );
132            return;
133        }
134
135        // (T383156): Only act if this is (a) a change to a Function or a linked Imp/Test & (b) the kind we care about.
136        switch ( $changeData['type'] ) {
137            case ZTypeRegistry::Z_FUNCTION:
138                // For consistency, we'll include this even when it's the Function itself that changed
139                $changeData['function'] = $changedObject;
140                $this->logger->debug(
141                    __METHOD__ . ': Handling edit to a Function {obj} revision {rev}',
142                    [
143                        'obj' => $changedObject,
144                        'rev' => $newId
145                    ]
146                );
147                break;
148
149            case ZTypeRegistry::Z_IMPLEMENTATION:
150                $targetFunctionZid = $newTargetZObject->getInnerZObject()->getValueByKey(
151                    ZTypeRegistry::Z_IMPLEMENTATION_FUNCTION
152                )->getZValue();
153
154                $targetContent = $this->zObjectStore->fetchZObjectByTitle( Title::newFromDBkey( $targetFunctionZid ) );
155                if ( !( $targetContent instanceof ZObjectContent ) ) {
156                    // Something has gone wrong; let's exit.
157                    return;
158                }
159                $targetFunction = $targetContent->getInnerZObject();
160                '@phan-var \MediaWiki\Extension\WikiLambda\ZObjects\ZFunction $targetFunction';
161                $approvedImplementations = $targetFunction->getImplementationZids();
162                if ( !in_array( $changedObject, $approvedImplementations ) ) {
163                    // This isn't an approved Implementation, so we don't care.
164                    return;
165                }
166
167                $this->logger->debug(
168                    __METHOD__ . ': Handling edit to a connected Implementation {obj} revision {rev} of Function {fun}',
169                    [
170                        'obj' => $changedObject,
171                        'rev' => $newId,
172                        'fun' => $targetFunctionZid
173                    ]
174                );
175
176                $changeData['function'] = $targetFunctionZid;
177                break;
178
179            case ZTypeRegistry::Z_TESTER:
180                $targetFunctionZid = $newTargetZObject->getInnerZObject()->getValueByKey(
181                    ZTypeRegistry::Z_TESTER_FUNCTION
182                )->getZValue();
183
184                $targetContent = $this->zObjectStore->fetchZObjectByTitle( Title::newFromDBkey( $targetFunctionZid ) );
185                if ( !( $targetContent instanceof ZObjectContent ) ) {
186                    // Something has gone wrong; let's exit.
187                    return;
188                }
189                $targetFunction = $targetContent->getInnerZObject();
190                '@phan-var \MediaWiki\Extension\WikiLambda\ZObjects\ZFunction $targetFunction';
191                $approvedTesters = $targetFunction->getTesterZids();
192                if ( !in_array( $changedObject, $approvedTesters ) ) {
193                    // This isn't an approved Tester, so we don't care.
194                    return;
195                }
196
197                $this->logger->debug(
198                    __METHOD__ . ': Handling edit to a connected Tester {obj} revision {rev} of Function {fun}',
199                    [
200                        'obj' => $changedObject,
201                        'rev' => $newId,
202                        'fun' => $targetFunctionZid
203                    ]
204                );
205
206                $changeData['function'] = $targetFunctionZid;
207                break;
208
209            default:
210                // We only care about certain ZObjects
211                $this->logger->debug(
212                    __METHOD__ . ': Ignoring edit to an irrelevant Object {obj} revision {rev}',
213                    [
214                        'obj' => $changedObject,
215                        'rev' => $newId
216                    ]
217                );
218                return;
219        }
220
221        // Now, we construct the changes that were made in the edit we're being told about.
222        // If we've already decided above that this is a deletion/undeletion, do nothing else.
223        if ( $logType === null ) {
224            // If this has been created, short-circuit
225            if ( $recentChange->getAttribute( 'rc_source' ) === RecentChange::SRC_NEW ) {
226                $changeData['action'] = 'create';
227
228                $this->logger->debug(
229                    __METHOD__ . ': Handled creation of {obj} revision {rev}',
230                    [
231                        'obj' => $changedObject,
232                        'rev' => $newId
233                    ]
234                );
235            } else {
236                $changeData['action'] = 'edit';
237                $changeData['operations'] = [];
238
239                $oldId = $recentChange->getAttribute( 'rc_last_oldid' );
240                if ( !$oldId ) {
241                    // This is a new page, so there's nothing to diff against
242                    $fromContentObject = '';
243                } else {
244                    $fromContentObject = $this->roundTripJson(
245                        $this->zObjectStore->fetchZObjectByTitle( $targetTitle, $oldId )->getObject()
246                    );
247                }
248
249                $toContentObject = $this->roundTripJson( $newTargetZObject->getObject() );
250
251                // TODO (T389090): Consider refactoring the below to use the same code as in ZObjectAuthorization?
252                $differ = new ZObjectDiffer();
253                $diffOps = $differ->doDiff( $fromContentObject, $toContentObject );
254                $flattedDiffOps = ZObjectDiffer::flattenDiff( $diffOps );
255
256                // Filter out irrelevant changes (e.g. label changed)
257                foreach ( $flattedDiffOps as $index => $diffOp ) {
258
259                    $firstPathElement = ( $diffOp['path'] === [] )
260                        // If the edit is a creation, the 'path' will not be useful
261                        ? ZTypeRegistry::Z_PERSISTENTOBJECT_VALUE
262                        : $diffOp['path'][0];
263
264                    $lastPathElement = ( is_numeric( end( $diffOp['path'] ) ) )
265                        // Bump up a layer if this is an array value change
266                        ? $diffOp['path'][count( $diffOp['path'] ) - 2]
267                        : end( $diffOp['path'] );
268
269                    // Discard irrelevant label/alias/etc. changes
270                    if (
271                        // Any changes not to the Z2K2 (e.g. label/alias/short description changes)
272                        ( $firstPathElement !== ZTypeRegistry::Z_PERSISTENTOBJECT_VALUE ) ||
273                        // Any changes to a Z12K1 (i.e. addition/removal of a label or short description)
274                        ( $lastPathElement === ZTypeRegistry::Z_MULTILINGUALSTRING_VALUE ) ||
275                        // Any changes to a Z11K2 (i.e. change of a label or short description)
276                        ( $lastPathElement === ZTypeRegistry::Z_MONOLINGUALSTRING_VALUE ) ||
277                        // Any changes to a Z32K1 (i.e. addition/removal of an alias)
278                        ( $lastPathElement === ZTypeRegistry::Z_MULTILINGUALSTRINGSET_VALUE ) ||
279                        // Any changes to a Z31K2 (i.e. change of an alias)
280                        ( $lastPathElement === ZTypeRegistry::Z_MONOLINGUALSTRINGSET_VALUE )
281                    ) {
282                        // Given the above, we don't care about this change, so skip to the next (if any)
283                        $this->logger->debug(
284                            __METHOD__ . ': Ignoring label-only edit to Object {obj} revision {rev}',
285                            [
286                                'obj' => $changedObject,
287                                'rev' => $newId
288                            ]
289                        );
290
291                        unset( $flattedDiffOps[$index] );
292                        continue;
293                    }
294
295                    // (T383156): At this point we think this is a relevant change, so build structured data about what
296                    // changed (e.g. implementation approved, tester value edited) so it can be sent to client wikis.
297
298                    // Edits to a Function, mostly additions/removals of Implementations or Testers
299                    if ( $newTargetZObject->getZType() === ZTypeRegistry::Z_FUNCTION ) {
300
301                        $changeType = $diffOp['op']->getType();
302
303                        // Note: We don't handle 'copy' operations, as they're invalid in our model
304                        if ( !in_array( $changeType, [ 'add', 'remove', 'change' ] ) ) {
305                            $this->logger->error(
306                                __METHOD__ . ': Unhandled {type} diff operation on {obj} revision {rev}: {diffOp}',
307                                [
308                                    'type' => $changeType,
309                                    'obj' => $changedObject,
310                                    'rev' => $newId,
311                                    'diffOp' => var_export( $diffOp, true )
312                                ]
313                            );
314                            continue;
315                        }
316
317                        if ( $changeType === 'add' ) {
318                            $changeData['operations'][implode( '.', $diffOp['path'] )][$changeType][] =
319                                $diffOp['op']->getNewValue();
320                        }
321
322                        if ( $changeType === 'remove' ) {
323                            $changeData['operations'][implode( '.', $diffOp['path'] )][$changeType][] =
324                                $diffOp['op']->getOldValue();
325                        }
326
327                        if ( $changeType === 'change' ) {
328                            $changeData['operations'][implode( '.', $diffOp['path'] )][$changeType][] = [
329                                $diffOp['op']->getOldValue(),
330                                $diffOp['op']->getNewValue()
331                            ];
332                        }
333
334                        $this->logger->debug(
335                            __METHOD__ . ': Handled Imp/Test approval {type} diff on Function {obj} revision {rev}',
336                            [
337                                'type' => $diffOp['op']->getType(),
338                                'obj' => $changedObject,
339                                'rev' => $newId
340                            ]
341                        );
342
343                        // We've handled this change, so move on to the next
344                        continue;
345                    }
346
347                    if (
348                        // If we're covering a change to a connected Implementation/Tester, we just care that it changed
349                        $newTargetZObject->getZType() === ZTypeRegistry::Z_IMPLEMENTATION ||
350                        $newTargetZObject->getZType() === ZTypeRegistry::Z_TESTER
351                    ) {
352                        $changeData['operations'][$newTargetZObject->getZType()] = implode( '.', $diffOp['path'] );
353
354                        $this->logger->debug(
355                            __METHOD__ . ': Handled edit of approved Imp/Test on {type} {obj} revision {rev}',
356                            [
357                                'type' => $newTargetZObject->getZType(),
358                                'obj' => $changedObject,
359                                'rev' => $newId
360                            ]
361                        );
362
363                        // We've handled this change, so move on to the next
364                        continue;
365                    }
366
367                    $this->logger->error(
368                        __METHOD__ . ': Unhandled diff operation on {changedObject} revision {revision}: {diffOp}',
369                        [
370                            'changedObject' => $changedObject,
371                            'revision' => $newId,
372                            'diffOp' => var_export( $diffOp, true )
373                        ]
374                    );
375                    return;
376                }
377
378                if ( count( $flattedDiffOps ) === 0 ) {
379                    // No relevant changes left after filtering
380                    $this->logger->debug(
381                        __METHOD__ . ': No interesting diff operations on {changedObject} revision {revision}',
382                        [
383                            'changedObject' => $changedObject,
384                            'revision' => $newId
385                        ]
386                    );
387                    return;
388                }
389
390                // TODO (T383156): Add labels for this Function for the UX to render (? all languages)
391            }
392        }
393        $changeData['oldId'] = $oldId ?? 0;
394
395        $generalUpdateJob = new WikifunctionsClientFanOutQueueJob( [
396            'target' => $targetPage->getDBkey(),
397            'timestamp' => $recentChange->getAttribute( 'rc_timestamp' ),
398            'summary' => $changeComment,
399            'data' => $changeData,
400            'user' => $recentChange->getPerformerIdentity()->getId(),
401            'bot' => $recentChange->getAttribute( 'rc_bot' ),
402        ] );
403
404        $jobQueueGroup = MediaWikiServices::getInstance()->getJobQueueGroup();
405        $jobQueueGroup->lazyPush( $generalUpdateJob );
406
407        // The return value isn't used, but we return something so we can show in tests that we reached this point
408        return true;
409    }
410
411    /**
412     * Utility function to round-trip data through JSON encoding/decoding
413     *
414     * @param mixed $data
415     * @return array
416     */
417    private function roundTripJson( $data ): array {
418        return json_decode( json_encode( $data ), true );
419    }
420
421}