Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.41% covered (success)
93.41%
156 / 167
33.33% covered (danger)
33.33%
1 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
WikifunctionsRecentChangesInsertJob
93.41% covered (success)
93.41%
156 / 167
33.33% covered (danger)
33.33%
1 / 3
32.29
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 run
99.31% covered (success)
99.31%
144 / 145
0.00% covered (danger)
0.00%
0 / 1
27
 getExternalUserNames
47.37% covered (danger)
47.37%
9 / 19
0.00% covered (danger)
0.00%
0 / 1
6.33
1<?php
2
3/**
4 * @file
5 * @ingroup Extensions
6 * @copyright 2020– Abstract Wikipedia team; see AUTHORS.txt
7 * @license MIT
8 */
9
10namespace MediaWiki\Extension\WikiLambda\Jobs;
11
12use MediaWiki\Extension\WikiLambda\Registry\ZTypeRegistry;
13use MediaWiki\Extension\WikiLambda\WikiLambdaServices;
14use MediaWiki\JobQueue\GenericParameterJob;
15use MediaWiki\JobQueue\Job;
16use MediaWiki\Logger\LoggerFactory;
17use MediaWiki\MediaWikiServices;
18use MediaWiki\RecentChanges\RecentChange;
19use MediaWiki\Title\Title;
20use MediaWiki\User\CentralId\CentralIdLookup;
21use MediaWiki\User\ExternalUserNames;
22use MediaWiki\WikiMap\WikiMap;
23use Psr\Log\LoggerInterface;
24
25/**
26 * Foo
27 */
28class WikifunctionsRecentChangesInsertJob extends Job implements GenericParameterJob {
29
30    /**
31     * @var string
32     */
33    public const SRC_WIKIFUNCTIONS = 'wf';
34
35    private LoggerInterface $logger;
36    private ?CentralIdLookup $centralIdLookup;
37
38    /**
39     * @var array Array of job parameters
40     * @phan-var array<string,mixed>
41     */
42    public $params;
43
44    public function __construct( array $params ) {
45        // This job, triggered from RecentChanges activity, takes the edit and fans it out to each
46        // relevant client wiki to process locally.
47
48        // Note: This will set $this->params.
49        parent::__construct( 'wikifunctionsRecentChangesInsert', $params );
50
51        // Non-injected items
52        $this->logger = LoggerFactory::getInstance( 'WikiLambdaClient' );
53
54        // We use the shared (CentralAuth) lookup to map the repo wiki's user to this wiki. If null, this
55        // wiki isn't connected to a central system and we can't attribute cross-wiki changes (see run()).
56        $this->centralIdLookup = MediaWikiServices::getInstance()->getCentralIdLookupFactory()->getNonLocalLookup();
57    }
58
59    public function run(): bool {
60        // Which local pages would the job be updating? Read from the shared cross-wiki
61        // usage table, scoped to this wiki.
62        $usageStore = WikiLambdaServices::getWikifunctionsUsageStore();
63        $pageIdsUsingFunction = $usageStore->fetchUsagePageIdsForWiki(
64            $this->params['target'],
65            WikiMap::getCurrentWikiId()
66        );
67
68        $this->logger->debug(
69            __CLASS__ . ': Processing a change for {targetZObject}',
70            [
71                'targetZObject' => $this->params['target']
72            ]
73        );
74
75        // Work out whether the job is still needed
76        if ( count( $pageIdsUsingFunction ) === 0 ) {
77            // We were triggered by the repo, but we aren't using that Function.
78            // Note: Until T385630 is done, this is acting-as-expected, and shouldn't be a source of concern.
79            $this->logger->debug(
80                __CLASS__ . ' triggered for {item} but it is unused; T385630 would avoid this',
81                [
82                    'item' => $this->params['target'],
83                ]
84            );
85            return true;
86        }
87
88        $services = MediaWikiServices::getInstance();
89        $dbw = $services->getConnectionProvider()->getPrimaryDatabase();
90        $commentStore = $services->getCommentStore();
91
92        // Build the RecentChange attributes common to all entries regardless of page on which it's used
93        $generalAttributes = [
94            'rc_source' => self::SRC_WIKIFUNCTIONS,
95
96            // Our standard flags, invariant between changes: never minor or deletes or creates
97            'rc_minor' => false,
98            'rc_deleted' => false,
99
100            // There's no patrollable state for this change entry, as it doesn't take place on this wiki
101            'rc_patrolled' => RecentChange::PRC_AUTOPATROLLED,
102
103            // Log-related things, all set empty (this is not a log entry)
104            'rc_logid' => 0,
105            'rc_log_type' => null,
106            'rc_log_action' => '',
107            'rc_params' => '',
108
109            // Update-specific stuff
110            'rc_bot' => $this->params['bot'],
111            'rc_timestamp' => wfTimestamp( TS_MW, $this->params['timestamp'] ),
112        ];
113
114        $changeData = $this->params['data'];
115        $changeAction = $changeData['action'];
116
117        if ( !$changeAction || !in_array( $changeAction, [ 'delete', 'restore', 'edit' ] ) ) {
118            $this->logger->warning(
119                __CLASS__ . ' triggered for {item} with unrecognised action {action}; data error?',
120                [
121                    'item' => $this->params['target'],
122                    'action' => var_export( $this->params, true ),
123                    // 'action' => $changeAction,
124                ]
125            );
126            return true;
127        }
128
129        if ( $changeAction !== 'edit' ) {
130            switch ( $changeData['type'] ) {
131                case ZTypeRegistry::Z_FUNCTION:
132                    // Used messages:
133                    // - wikilambda-recentchanges-explanation-delete-function
134                    // - wikilambda-recentchanges-explanation-restore-function
135                    $changeData['message'] = "wikilambda-recentchanges-explanation-$changeAction-function";
136                    break;
137
138                case ZTypeRegistry::Z_IMPLEMENTATION:
139                    // Used messages:
140                    // - wikilambda-recentchanges-explanation-delete-implementation
141                    // - wikilambda-recentchanges-explanation-restore-implementation
142                    $changeData['message'] = "wikilambda-recentchanges-explanation-$changeAction-implementation";
143                    $changeData['messageParams'] = [ $changeData['target'] ];
144                    break;
145
146                case ZTypeRegistry::Z_TESTER:
147                    // Used messages:
148                    // - wikilambda-recentchanges-explanation-delete-tester
149                    // - wikilambda-recentchanges-explanation-restore-tester
150                    $changeData['message'] = "wikilambda-recentchanges-explanation-$changeAction-tester";
151                    $changeData['messageParams'] = [ $changeData['target'] ];
152                    break;
153
154                default:
155                    // Unrecognised type; just exit, and log for follow-up
156                    $this->logger->warning(
157                        __CLASS__ . ' triggered for {item} deletion/undeletion with unknown type {type}; data error?',
158                        [
159                            'item' => $this->params['target'],
160                            'action' => $changeData['type'],
161                        ]
162                    );
163                    return true;
164            }
165        } else {
166            // Note: We just pick the first of multiple operations, as that's what the UX allows you to do. However, if
167            // e.g. someone did an API edit that added some Implementations & removed some Testers, we'll show only one.
168            $operations = $changeData['operations'];
169
170            switch ( $changeData['type'] ) {
171                case ZTypeRegistry::Z_FUNCTION:
172                    // Changes to Functions are complex – direct errors, and changes to approved Implementations/Testers
173                    $actionPath = array_key_first( $operations );
174
175                    switch ( $actionPath ) {
176                        case ZTypeRegistry::Z_FUNCTION_IMPLEMENTATIONS:
177                        case ZTypeRegistry::Z_FUNCTION_TESTERS:
178                            $typeTouched = ( $actionPath === ZTypeRegistry::Z_FUNCTION_IMPLEMENTATIONS )
179                                ? 'implementations' : 'testers';
180                            $action = array_key_first( $operations[$actionPath] );
181                            $affected = $operations[$actionPath][$action];
182
183                            if ( $action === 'add' ) {
184                                $changeAction = 'connect';
185                            } elseif ( $action === 'remove' ) {
186                                $changeAction = 'disconnect';
187                            }
188
189                            $lang = $services->getContentLanguage();
190
191                            // Used messages:
192                            // - wikilambda-recentchanges-explanation-connect-implementation
193                            // - wikilambda-recentchanges-explanation-disconnect-implementation
194                            // - wikilambda-recentchanges-explanation-connect-tester
195                            // - wikilambda-recentchanges-explanation-disconnect-tester
196                            $changeData['message'] = "wikilambda-recentchanges-explanation-$changeAction-$typeTouched";
197                            $changeData['messageParams'] = [ count( $affected ), $lang->listToText( $affected ) ];
198                            break;
199
200                        default:
201                            // The edit was to something other than the approved Implementations or Testers; use generic
202                            $changeData['message'] = 'wikilambda-recentchanges-explanation-edit-function';
203                            break;
204                    }
205                    break;
206
207                case ZTypeRegistry::Z_IMPLEMENTATION:
208                    $changeData['message'] = 'wikilambda-recentchanges-explanation-edit-implementation';
209                    break;
210
211                case ZTypeRegistry::Z_TESTER:
212                    $changeData['message'] = 'wikilambda-recentchanges-explanation-edit-tester';
213                    break;
214
215                default:
216                    // Unrecognised type; just exit, and log for follow-up
217                    $this->logger->warning(
218                        __CLASS__ . ' triggered for {item} with unrecognised type {type}; data error?',
219                        [
220                            'item' => $this->params['target'],
221                            'action' => $changeData['type'],
222                        ]
223                    );
224                    return true;
225            }
226        }
227
228        $comment = $commentStore->createComment( $dbw, $this->params['summary'] ?? '' );
229
230        // Ideally we'd ask the CommentStore if it has an existing Comment ID for this string and re-use that, but
231        // that facility isn't available, so we'll just insert the raw string as a new field and let RC deal.
232        $generalAttributes['rc_comment'] = $comment->text;
233
234        // Attribute the change to the repo performer. We identify them by a shared central (CentralAuth)
235        // id computed on the repo wiki; without one (no central system, or an unattached/anonymous repo
236        // user) there's nothing we can safely map, so skip rather than guess.
237        $centralUserId = $this->params['centralUserId'] ?? 0;
238        if ( !$this->centralIdLookup || !$centralUserId ) {
239            $this->logger->warning(
240                __CLASS__ . ' has no central user id for the performer of {target}; skipping RecentChange insert',
241                [ 'target' => $this->params['target'] ]
242            );
243            return true;
244        }
245
246        $localUser = $this->centralIdLookup->localUserFromCentralId( $centralUserId );
247        if ( $localUser ) {
248            // They have an attached local account; attribute to it directly.
249            $generalAttributes['rc_user'] = $localUser->getId();
250            $generalAttributes['rc_user_text'] = $localUser->getName();
251        } else {
252            // No local account: attribute to the global user name as an interwiki user, exactly as
253            // Wikibase does for foreign edits, so the row still records and links. We resolve the name
254            // fresh from CentralAuth (rather than plumbing it from the repo) so renames are reflected.
255            $globalName = $this->centralIdLookup->nameFromCentralId( $centralUserId );
256            $externalUserNames = $this->getExternalUserNames();
257            if ( $globalName === null || $globalName === '' || !$externalUserNames ) {
258                $this->logger->error(
259                    __CLASS__ . ' could not resolve a global user name for central id {centralUserId} on {target}; '
260                        . 'skipping RecentChange insert',
261                    [ 'centralUserId' => $centralUserId, 'target' => $this->params['target'] ]
262                );
263                return true;
264            }
265            $generalAttributes['rc_user'] = 0;
266            $generalAttributes['rc_user_text'] = $externalUserNames->addPrefix( $globalName );
267        }
268
269        // We can't stuff non-strings into the rc_params field, so we need to JSON-ify it
270        $generalAttributes['rc_params'] = json_encode( $changeData );
271
272        // The usage table stores page IDs; resolve each to its current local Title. Using
273        // the ID (rather than a stored title string) is rename-safe and gives the page's
274        // live namespace, title, length and revision.
275        foreach ( $pageIdsUsingFunction as $pageId ) {
276            $title = Title::newFromID( $pageId );
277            if ( !$title ) {
278                // The page has since been deleted; skip it. Its usage row will be cleared
279                // by the page-deletion handler.
280                continue;
281            }
282
283            $titleSpecificAttribs = [
284                'rc_namespace' => $title->getNamespace(),
285                'rc_title' => $title->getDBkey(),
286                // As we're not adding an edit, we just re-use the most recent edit ID for the page
287                'rc_cur_id' => $title->getArticleID(),
288
289                // We're not changing the page, just faking it, so
290                // … old and new lengths are the same, and …
291                'rc_old_len' => $title->getLength(),
292                'rc_new_len' => $title->getLength(),
293
294                // … old and new revisions are the also same
295                'rc_this_oldid' => $title->getLatestRevID(),
296                'rc_last_oldid' => $title->getLatestRevID(),
297            ];
298
299            $changeAttributes = $generalAttributes + $titleSpecificAttribs;
300
301            $this->logger->debug(
302                __CLASS__ . ': Inserting a RecentChange for {targetZObject} on page ID {target}',
303                [
304                    'targetZObject' => $this->params['target'],
305                    'target' => $pageId
306                ]
307            );
308
309            $changeEntry = new RecentChange();
310            $changeEntry->setAttribs( $changeAttributes );
311            $changeEntry->setExtra( $changeData );
312            $changeEntry->save();
313        }
314
315        return true;
316    }
317
318    /**
319     * Build an ExternalUserNames for the repo wiki, used to attribute changes by repo users who have
320     * no local account here. The repo's interwiki prefix is taken from its entry in the sites table,
321     * identified by the WikiLambdaClientRepoSiteId global key.
322     *
323     * @return ExternalUserNames|null Null if unconfigured, or the repo site/prefix can't be resolved.
324     */
325    private function getExternalUserNames(): ?ExternalUserNames {
326        $services = MediaWikiServices::getInstance();
327
328        $repoSiteId = $services->getMainConfig()->get( 'WikiLambdaClientRepoSiteId' );
329        if ( !$repoSiteId ) {
330            return null;
331        }
332
333        $repoSite = $services->getSiteLookup()->getSite( $repoSiteId );
334        if ( $repoSite === null ) {
335            $this->logger->warning(
336                __CLASS__ . ': repo site {siteId} is not a known global site key',
337                [ 'siteId' => $repoSiteId ]
338            );
339            return null;
340        }
341
342        $interwikiPrefixes = $repoSite->getInterwikiIds();
343        if ( $interwikiPrefixes === [] ) {
344            $this->logger->warning(
345                __CLASS__ . ': repo site {siteId} has no interwiki prefix',
346                [ 'siteId' => $repoSiteId ]
347            );
348            return null;
349        }
350
351        return new ExternalUserNames( $interwikiPrefixes[0], false );
352    }
353}