Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
8.81% covered (danger)
8.81%
14 / 159
0.00% covered (danger)
0.00%
0 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
MentorHooks
8.81% covered (danger)
8.81%
14 / 159
0.00% covered (danger)
0.00%
0 / 11
808.63
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 onBeforeCreateEchoEvent
0.00% covered (danger)
0.00%
0 / 46
0.00% covered (danger)
0.00%
0 / 1
2
 handleForceMentor
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
30
 onLocalUserCreated
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
42
 onAuthChangeFormFields
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 onPageSaveComplete
75.00% covered (warning)
75.00%
9 / 12
0.00% covered (danger)
0.00%
0 / 1
4.25
 onListDefinedTags
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onChangeTagsListActive
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onFormatAutocomments
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
12
 onUserGetRights
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
4.07
 onBeforePageDisplay
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2
3namespace GrowthExperiments\Mentorship\Hooks;
4
5use EchoAttributeManager;
6use EchoUserLocator;
7use GenderCache;
8use GrowthExperiments\MentorDashboard\PersonalizedPraise\EchoNewPraiseworthyMenteesPresentationModel;
9use GrowthExperiments\Mentorship\EchoMenteeClaimPresentationModel;
10use GrowthExperiments\Mentorship\EchoMentorChangePresentationModel;
11use GrowthExperiments\Mentorship\MentorManager;
12use GrowthExperiments\Mentorship\Provider\MentorProvider;
13use GrowthExperiments\Mentorship\Provider\StructuredMentorWriter;
14use GrowthExperiments\Mentorship\Store\MentorStore;
15use GrowthExperiments\Util;
16use MediaWiki\Auth\Hook\LocalUserCreatedHook;
17use MediaWiki\ChangeTags\Hook\ChangeTagsListActiveHook;
18use MediaWiki\ChangeTags\Hook\ListDefinedTagsHook;
19use MediaWiki\Config\Config;
20use MediaWiki\Deferred\DeferredUpdates;
21use MediaWiki\Hook\BeforePageDisplayHook;
22use MediaWiki\Hook\FormatAutocommentsHook;
23use MediaWiki\Permissions\Hook\UserGetRightsHook;
24use MediaWiki\SpecialPage\Hook\AuthChangeFormFieldsHook;
25use MediaWiki\Storage\Hook\PageSaveCompleteHook;
26use MediaWiki\User\UserIdentity;
27use MediaWiki\User\UserIdentityLookup;
28use Psr\Log\LogLevel;
29use RequestContext;
30use Throwable;
31use Wikimedia\LightweightObjectStore\ExpirationAwareness;
32use Wikimedia\Timestamp\ConvertibleTimestamp;
33
34class MentorHooks implements
35    LocalUserCreatedHook,
36    AuthChangeFormFieldsHook,
37    PageSaveCompleteHook,
38    ListDefinedTagsHook,
39    ChangeTagsListActiveHook,
40    FormatAutocommentsHook,
41    UserGetRightsHook,
42    BeforePageDisplayHook
43{
44
45    private Config $config;
46    private Config $wikiConfig;
47    private UserIdentityLookup $userIdentityLookup;
48    private GenderCache $genderCache;
49    private MentorManager $mentorManager;
50    private MentorProvider $mentorProvider;
51    private MentorStore $mentorStore;
52
53    /**
54     * @param Config $config
55     * @param Config $wikiConfig
56     * @param UserIdentityLookup $userIdentityLookup
57     * @param GenderCache $genderCache
58     * @param MentorManager $mentorManager
59     * @param MentorProvider $mentorProvider
60     * @param MentorStore $mentorStore
61     */
62    public function __construct(
63        Config $config,
64        Config $wikiConfig,
65        UserIdentityLookup $userIdentityLookup,
66        GenderCache $genderCache,
67        MentorManager $mentorManager,
68        MentorProvider $mentorProvider,
69        MentorStore $mentorStore
70    ) {
71        $this->config = $config;
72        $this->wikiConfig = $wikiConfig;
73        $this->userIdentityLookup = $userIdentityLookup;
74        $this->genderCache = $genderCache;
75        $this->mentorManager = $mentorManager;
76        $this->mentorProvider = $mentorProvider;
77        $this->mentorStore = $mentorStore;
78    }
79
80    /**
81     * Add Mentorship events to Echo
82     *
83     * @param array &$notifications array of Echo notifications
84     * @param array &$notificationCategories array of Echo notification categories
85     * @param array &$icons array of icon details
86     */
87    public static function onBeforeCreateEchoEvent(
88        &$notifications, &$notificationCategories, &$icons
89    ) {
90        $notificationCategories['ge-mentorship'] = [
91            'tooltip' => 'echo-pref-tooltip-ge-mentorship',
92        ];
93
94        $notifications['mentor-changed'] = [
95            'category' => 'system',
96            'group' => 'positive',
97            'section' => 'alert',
98            'presentation-model' => EchoMentorChangePresentationModel::class,
99            EchoAttributeManager::ATTR_LOCATORS => [
100                [
101                    [ EchoUserLocator::class, 'locateFromEventExtra' ],
102                    [ 'mentee' ]
103                ],
104            ],
105        ];
106        $notifications['mentee-claimed'] = [
107            'category' => 'ge-mentorship',
108            'group' => 'positive',
109            'section' => 'message',
110            'presentation-model' => EchoMenteeClaimPresentationModel::class,
111            EchoAttributeManager::ATTR_LOCATORS => [
112                [
113                    [ EchoUserLocator::class, 'locateFromEventExtra' ],
114                    [ 'mentor' ]
115                ]
116            ]
117        ];
118        $notifications['new-praiseworthy-mentees'] = [
119            'category' => 'ge-mentorship',
120            'group' => 'positive',
121            'section' => 'message',
122            'canNotifyAgent' => true,
123            'presentation-model' => EchoNewPraiseworthyMenteesPresentationModel::class,
124            EchoAttributeManager::ATTR_LOCATORS => [
125                EchoUserLocator::class . '::locateEventAgent'
126            ]
127        ];
128
129        $icons['growthexperiments-mentor'] = [
130            'path' => [
131                'ltr' => 'GrowthExperiments/images/mentor-ltr.svg',
132                'rtl' => 'GrowthExperiments/images/mentor-rtl.svg'
133            ]
134        ];
135        // T332732: In he, the mentor icon should be displayed in LTR
136        $icons['growthexperiments-mentor-ltr'] = [
137            'path' => 'GrowthExperiments/images/mentor-ltr.svg'
138        ];
139    }
140
141    /**
142     * Handles `forceMentor` parameter, if present
143     *
144     * This method checks forceMentor query parameter. If it is present, it:
145     *
146     *     1) gets one or more username from it (| is used as the delimiter)
147     *     2) remove all non-mentors from the lists (determined via MentorProvider::isMentor)
148     *     3) assigns a random mentor from the list to $user
149     *     4) generates a random backup mentor (who may or may not be in the list)
150     *
151     * If no forceMentor parameter is provided (or if it does not contain mentors' usernames),
152     * the method short-circuits and returns false.
153     *
154     * @param UserIdentity $user Newly created user
155     * @return bool returns true if a mentor was assigned to the user (if false is returned,
156     * the caller is responsible for assigning a mentor to the user)
157     */
158    private function handleForceMentor( UserIdentity $user ): bool {
159        $forceMentorRaw = RequestContext::getMain()->getRequest()
160            ->getVal( 'forceMentor', '' );
161        if ( $forceMentorRaw === '' ) {
162            return false;
163        }
164
165        $forceMentorNames = explode( '|', $forceMentorRaw );
166        $forceMentors = array_filter( array_map(
167            function ( $username ) {
168                $user = $this->userIdentityLookup->getUserIdentityByName( $username );
169                if ( !$user ) {
170                    return null;
171                }
172                if ( !$this->mentorProvider->isMentor( $user ) ) {
173                    return null;
174                }
175                return $user;
176            },
177            $forceMentorNames
178        ) );
179
180        if ( $forceMentors ) {
181            $forcedPrimaryMentor = $forceMentors[ array_rand( $forceMentors ) ];
182
183            $this->mentorStore->setMentorForUser(
184                $user,
185                $forcedPrimaryMentor,
186                MentorStore::ROLE_PRIMARY
187            );
188            // Select a random backup mentor
189            $this->mentorManager->getMentorForUser( $user, MentorStore::ROLE_BACKUP );
190            return true;
191        }
192        return false;
193    }
194
195    /** @inheritDoc */
196    public function onLocalUserCreated( $user, $autocreated ) {
197        if ( $autocreated || $user->isTemp() ) {
198            // Excluding autocreated users is necessary, see T276720
199            return;
200        }
201        if ( $this->wikiConfig->get( 'GEMentorshipEnabled' ) ) {
202            try {
203                if ( $this->handleForceMentor( $user ) ) {
204                    return;
205                }
206
207                // Select a primary & backup mentor. FIXME Not really necessary, but avoids a
208                // change in functionality after introducing MentorManager, making debugging easier.
209                $this->mentorManager->getMentorForUser( $user, MentorStore::ROLE_PRIMARY );
210                $this->mentorManager->getMentorForUser( $user, MentorStore::ROLE_BACKUP );
211            } catch ( Throwable $throwable ) {
212                Util::logException( $throwable, [
213                    'user' => $user->getId(),
214                    'impact' => 'Failed to assign mentor for user',
215                    'origin' => __METHOD__,
216                ], LogLevel::INFO );
217            }
218        }
219    }
220
221    /**
222     * Pass through the query parameter used by LocalUserCreated.
223     * @inheritDoc
224     */
225    public function onAuthChangeFormFields( $requests, $fieldInfo, &$formDescriptor, $action ) {
226        $forceMentor = RequestContext::getMain()->getRequest()
227            ->getVal( 'forceMentor', '' );
228        if ( $forceMentor !== null ) {
229            $formDescriptor['forceMentor'] = [
230                'type' => 'hidden',
231                'name' => 'forceMentor',
232                'default' => $forceMentor,
233            ];
234        }
235    }
236
237    /**
238     * @inheritDoc
239     */
240    public function onPageSaveComplete(
241        $wikiPage, $user, $summary, $flags, $revisionRecord, $editResult
242    ) {
243        DeferredUpdates::addCallableUpdate( function () use ( $wikiPage ) {
244            $title = $wikiPage->getTitle();
245
246            $sourceTitles = $this->mentorProvider->getSourceTitles();
247            foreach ( $sourceTitles as $sourceTitle ) {
248                if ( $sourceTitle->equals( $title ) ) {
249                    $this->mentorProvider->invalidateCache();
250                    break;
251                }
252            }
253        } );
254        DeferredUpdates::addCallableUpdate( function () use ( $user ) {
255            if ( $this->mentorStore->isMentee( $user ) ) {
256                $this->mentorStore->markMenteeAsActive( $user );
257            }
258        } );
259    }
260
261    /**
262     * @inheritDoc
263     */
264    public function onListDefinedTags( &$tags ) {
265        $tags[] = StructuredMentorWriter::CHANGE_TAG;
266    }
267
268    /**
269     * @inheritDoc
270     */
271    public function onChangeTagsListActive( &$tags ) {
272        $tags[] = StructuredMentorWriter::CHANGE_TAG;
273    }
274
275    /**
276     * @inheritDoc
277     */
278    public function onFormatAutocomments( &$comment, $pre, $auto, $post, $title, $local, $wikiId ) {
279        // NOTE: this message is no longer used, but parsing support needs to be kept to support
280        // older revisions.
281        $noParamMessageKeys = [
282            'growthexperiments-mentorship-enrollasmentor-summary',
283        ];
284        if ( in_array( $auto, $noParamMessageKeys ) ) {
285            $comment = wfMessage( $auto )->text();
286        }
287
288        $mentorChangeMessageKeys = [
289            'growthexperiments-manage-mentors-summary-add-admin-no-reason',
290            'growthexperiments-manage-mentors-summary-add-admin-with-reason',
291            'growthexperiments-manage-mentors-summary-add-self-no-reason',
292            'growthexperiments-manage-mentors-summary-add-self-with-reason',
293            'growthexperiments-manage-mentors-summary-change-admin-no-reason',
294            'growthexperiments-manage-mentors-summary-change-admin-with-reason',
295            'growthexperiments-manage-mentors-summary-change-self-no-reason',
296            'growthexperiments-manage-mentors-summary-change-self-with-reason',
297            'growthexperiments-manage-mentors-summary-remove-admin-no-reason',
298            'growthexperiments-manage-mentors-summary-remove-admin-with-reason',
299            'growthexperiments-manage-mentors-summary-remove-self-no-reason',
300            'growthexperiments-manage-mentors-summary-remove-self-with-reason',
301        ];
302
303        $messageParts = explode( ':', $auto, 2 );
304        $messageKey = $messageParts[0];
305        if ( in_array( $messageKey, $mentorChangeMessageKeys ) ) {
306            $comment = wfMessage( $messageKey )
307                ->params( ...explode( '|', $messageParts[1] ) )
308                ->inContentLanguage()
309                ->parse();
310        }
311    }
312
313    /**
314     * @inheritDoc
315     */
316    public function onUserGetRights( $user, &$rights ) {
317        if ( !$this->wikiConfig->get( 'GEMentorshipAutomaticEligibility' ) ) {
318            return;
319        }
320
321        // ConvertibleTimestamp::time() used so we can fake the current time in tests
322        $userAge = ConvertibleTimestamp::time() - (int)wfTimestampOrNull( TS_UNIX, $user->getRegistration() );
323        if (
324            $userAge >= $this->wikiConfig->get( 'GEMentorshipMinimumAge' ) * ExpirationAwareness::TTL_DAY &&
325            $user->getEditCount() >= $this->wikiConfig->get( 'GEMentorshipMinimumEditcount' )
326        ) {
327            $rights[] = 'enrollasmentor';
328        }
329    }
330
331    /**
332     * @inheritDoc
333     */
334    public function onBeforePageDisplay( $out, $skin ): void {
335        if ( $out->getRequest()->getBool( 'gepersonalizedpraise' ) ) {
336            $out->addModules( 'ext.growthExperiments.MentorDashboard.PostEdit' );
337
338            $jsConfigVars = [
339                'wgPostEditConfirmationDisabled' => true,
340                'wgGEMentorDashboardPersonalizedPraisePostEdit' => true,
341            ];
342
343            // NOTE: gepersonalizedpraise query parameter should be only passed in NS_USER_TALK,
344            // but verify that just in case
345            $title = $skin->getTitle();
346            if ( $title->getNamespace() === NS_USER_TALK ) {
347                $userIdentity = $this->userIdentityLookup->getUserIdentityByName( $skin->getTitle()->getText() );
348                if ( $userIdentity ) {
349                    $jsConfigVars['wgGEMentorDashboardPersonalizedPraiseMenteeGender'] = $this->genderCache
350                        ->getGenderOf( $userIdentity );
351                }
352            }
353            $out->addJsConfigVars( $jsConfigVars );
354        }
355    }
356}