Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
77.52% covered (warning)
77.52%
100 / 129
57.14% covered (warning)
57.14%
4 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
MigrateCommunityConfig
81.30% covered (warning)
81.30%
100 / 123
57.14% covered (warning)
57.14%
4 / 7
40.12
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 initServices
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 migrateToProvider
75.38% covered (warning)
75.38%
49 / 65
0.00% covered (danger)
0.00%
0 / 1
25.97
 doDBUpdates
87.88% covered (warning)
87.88%
29 / 33
0.00% covered (danger)
0.00%
0 / 1
5.04
 getUpdateKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getGrowthExperimentsCommunityConfig
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 hasValidationError
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3namespace GrowthExperiments\Maintenance;
4
5use GrowthExperiments\Config\MediaWikiConfigReaderWrapper;
6use GrowthExperiments\Config\WikiPageConfigLoader;
7use GrowthExperiments\GrowthExperimentsServices;
8use MediaWiki\Config\Config;
9use MediaWiki\Context\RequestContext;
10use MediaWiki\Extension\CommunityConfiguration\CommunityConfigurationServices;
11use MediaWiki\Extension\CommunityConfiguration\Provider\ConfigurationProviderFactory;
12use MediaWiki\Extension\CommunityConfiguration\Provider\IConfigurationProvider;
13use MediaWiki\Language\FormatterFactory;
14use MediaWiki\Maintenance\LoggedUpdateMaintenance;
15use MediaWiki\Permissions\UltimateAuthority;
16use MediaWiki\Title\MalformedTitleException;
17use MediaWiki\Title\TitleFactory;
18use MediaWiki\User\User;
19use StatusValue;
20use Wikimedia\Rdbms\IDBAccessObject;
21
22$IP = getenv( 'MW_INSTALL_PATH' );
23if ( $IP === false ) {
24    $IP = __DIR__ . '/../../..';
25}
26require_once "$IP/maintenance/Maintenance.php";
27
28/**
29 * Maintenance script for migrating existing community config on-wiki config files from the
30 * GrowthExperiments community configuration version 1.0 structure to the structure expected
31 * by the Community configuration (2.0) provider definitions in extension.json#CommunityConfiguration/Providers.
32 * These are:
33 *  - extension.json#CommunityConfiguration/Providers/HelpPanel
34 *  - extension.json#CommunityConfiguration/Providers/Mentorship
35 *  - extension.json#CommunityConfiguration/Providers/GrowthHomepage
36 *  - extension.json#CommunityConfiguration/Providers/GrowthSuggestedEdits
37 */
38class MigrateCommunityConfig extends LoggedUpdateMaintenance {
39
40    private const MIGRATION_ALLOW_LIST = [
41        'HelpPanel',
42        'GrowthHomepage',
43        'Mentorship',
44        'GrowthSuggestedEdits'
45    ];
46
47    // Map of existing rootProperty name to override name
48    // (the name used in the existing config page)
49    private const OVERRIDE_NAMES = [
50        'image_recommendation' => 'image-recommendation',
51        'section_image_recommendation' => 'section-image-recommendation',
52        'link_recommendation' => 'link-recommendation',
53    ];
54
55    private const SUGGESTED_EDITS_TARGET_PROPS = [
56        'copyedit',
57        'links',
58        'references',
59        'update',
60        'expand',
61        'section_image_recommendation',
62        'image_recommendation',
63        'link_recommendation',
64    ];
65
66    private const SUGGESTED_EDITS_AUTCOMPUTED_PROPS = [
67        'group',
68        'type'
69    ];
70
71    private const HELP_PANEL_TARGET_PROPS = [
72        'GEHelpPanelHelpDeskPostOnTop',
73        'GEHelpPanelAskMentor'
74    ];
75
76    private const HOMEPAGE_TARGET_PROPS = [
77        'GELevelingUpKeepGoingNotificationThresholds',
78    ];
79
80    /** @var IConfigurationProvider[] */
81    private array $providers;
82
83    private TitleFactory $titleFactory;
84
85    private WikiPageConfigLoader $wikiPageConfigLoader;
86
87    private ConfigurationProviderFactory $configurationProviderFactory;
88
89    private FormatterFactory $formatterFactory;
90
91    private Config $growthConfig;
92
93    public function __construct() {
94        parent::__construct();
95        $this->requireExtension( 'GrowthExperiments' );
96        $this->requireExtension( 'CommunityConfiguration' );
97        $this->addDescription(
98            'Migrate existing community configuration from on-wiki config files to new file locations'
99        );
100
101        $this->addOption( 'dry-run', 'Print the migration summary.' );
102    }
103
104    private function initServices() {
105        $services = $this->getServiceContainer();
106
107        $this->configurationProviderFactory = CommunityConfigurationServices::wrap( $services )
108            ->getConfigurationProviderFactory();
109        $extensionProviders = $this->configurationProviderFactory->getSupportedKeys();
110
111        foreach ( self::MIGRATION_ALLOW_LIST as $providerId ) {
112            if ( in_array( $providerId, $extensionProviders ) ) {
113                $this->providers[ $providerId ] = $this->configurationProviderFactory->newProvider( $providerId );
114            }
115        }
116
117        $geServices = GrowthExperimentsServices::wrap( $services );
118        $this->wikiPageConfigLoader = $geServices->getWikiPageConfigLoader();
119        $this->growthConfig = $geServices->getGrowthConfig();
120        $this->titleFactory = $services->getTitleFactory();
121        $this->formatterFactory = $services->getFormatterFactory();
122    }
123
124    /**
125     * Iterate over the given provider schema root properties and try
126     * to get a value for the same property name in the existing Growth config.
127     *
128     * @param IConfigurationProvider $provider
129     * @param array $config
130     * @param bool $dryRun
131     * @return array<array<string>,array<string>> The first element contains an array with the names of the migrated
132     * config options. The second element contains an array with the names of the config options specified in
133     * the provider schema but not present in the wiki growth config.
134     */
135    private function migrateToProvider( IConfigurationProvider $provider, array $config, bool $dryRun = false ): array {
136        $missingConfigs = [];
137        $migratedConfigs = [];
138        $rootProperties = $provider->getValidator()->getSchemaBuilder()->getRootProperties();
139        $providerId = $provider->getId();
140        $props = [];
141
142        foreach ( $rootProperties as $prop => $schema ) {
143            $maybeOverriddenProp = self::OVERRIDE_NAMES[ $prop ] ?? $prop;
144            // GrowthSuggestedEdits special casing
145            if ( $providerId === 'GrowthSuggestedEdits' ) {
146                if ( in_array( $prop, self::SUGGESTED_EDITS_TARGET_PROPS ) ) {
147                    // Unset autocomputed properties since they don't exist in the schema and the validator
148                    // will complain
149                    foreach ( self::SUGGESTED_EDITS_AUTCOMPUTED_PROPS as $autcomputedProp ) {
150                        unset( $config[$maybeOverriddenProp][$autcomputedProp] );
151                    }
152                }
153            }
154            // GrowthSuggestedEdits special casing
155            if ( $providerId === 'HelpPanel' ) {
156                if (
157                    in_array( $maybeOverriddenProp, self::HELP_PANEL_TARGET_PROPS ) &&
158                    isset( $config[$maybeOverriddenProp] )
159                ) {
160                    if ( $maybeOverriddenProp === 'GEHelpPanelHelpDeskPostOnTop' ) {
161                        // Apply same transforms as MediaWikiConfigReaderWrapper
162                        $config[$maybeOverriddenProp] = array_search(
163                            $config[$maybeOverriddenProp],
164                            MediaWikiConfigReaderWrapper::MAP_POST_ON_TOP_VALUES
165                        );
166
167                    }
168                    if ( $maybeOverriddenProp === 'GEHelpPanelAskMentor' ) {
169                        // Apply same transforms as MediaWikiConfigReaderWrapper
170                        $config[$maybeOverriddenProp] = array_search(
171                            $config[$maybeOverriddenProp],
172                            MediaWikiConfigReaderWrapper::MAP_ASK_MENTOR_VALUES
173                        );
174                    }
175                }
176            }
177
178            // GrowthSuggestedEdits special casing
179            if ( $providerId === 'GrowthHomepage' ) {
180                if (
181                    in_array( $maybeOverriddenProp, self::HOMEPAGE_TARGET_PROPS ) &&
182                    isset( $config[$maybeOverriddenProp] )
183                ) {
184                    if ( $maybeOverriddenProp === 'GELevelingUpKeepGoingNotificationThresholds' ) {
185                        $config[$maybeOverriddenProp] = $config[$maybeOverriddenProp][1];
186                    }
187                }
188            }
189
190            if ( isset( $config[$maybeOverriddenProp] ) ) {
191                $props[$prop] = $config[$maybeOverriddenProp];
192                $migratedConfigs[] = $maybeOverriddenProp;
193            } else {
194                $missingConfigs[] = $prop;
195            }
196        }
197
198        // Required to convert deeply nested arrays into objects
199        $objectProps = json_decode( json_encode( (object)$props ) );
200        if ( $dryRun ) {
201            $validationStatus = $provider->getValidator()->validateStrictly( $objectProps );
202            if ( !$validationStatus->isOK() ) {
203                if ( $this->hasValidationError( $validationStatus ) ) {
204                    $this->output( "Errors found:\n" );
205                }
206                $this->error(
207                    $this->formatterFactory->getStatusFormatter( RequestContext::getMain() )->getWikiText(
208                        $validationStatus
209                    )
210                );
211            }
212        } else {
213            $PHAB = 'T359038';
214            $storeStatus = $provider->storeValidConfiguration(
215                $objectProps,
216                new UltimateAuthority(
217                    User::newSystemUser( User::MAINTENANCE_SCRIPT_USER )
218                ),
219                "machine-generated configuration for migrating GrowthExperiments community configurable" .
220                " options to use CommunityConfiguration Extension ([[phab:$PHAB]])"
221            );
222            if ( !$storeStatus->isOK() ) {
223                if ( $this->hasValidationError( $storeStatus ) ) {
224                    $this->output( "Errors found:\n" );
225                }
226                $this->fatalError(
227                    $this->formatterFactory->getStatusFormatter( RequestContext::getMain() )->getWikiText(
228                        $storeStatus
229                    )
230                );
231            }
232
233        }
234        return [
235            $migratedConfigs,
236            $missingConfigs
237        ];
238    }
239
240    /**
241     * @inheritDoc
242     * @throws MalformedTitleException
243     */
244    protected function doDBUpdates() {
245        $allMigratedConfigs = [];
246        $this->initServices();
247        $dryRun = $this->hasOption( 'dry-run' );
248
249        $configBuckets = [
250            'growth' => $this->getGrowthExperimentsCommunityConfig(
251                $this->growthConfig->get( 'GEWikiConfigPageTitle' )
252            ),
253            'tasks' => $this->getGrowthExperimentsCommunityConfig(
254                $this->growthConfig->get( 'GENewcomerTasksConfigTitle' )
255            ),
256        ];
257
258        foreach ( $configBuckets as $bucket ) {
259            if ( $bucket instanceof StatusValue ) {
260                if ( !$bucket->isOK() ) {
261                    $this->fatalError(
262                        $this->formatterFactory->getStatusFormatter( RequestContext::getMain() )->getWikiText( $bucket )
263                    );
264                }
265            }
266        }
267
268        $config = array_merge( $configBuckets['tasks'], $configBuckets['growth'] );
269        foreach ( $this->providers as $providerId => $provider ) {
270            $this->output( 'Migrating ' . $providerId . "\n\n" );
271            [
272                $migratedConfigs,
273                $missingConfigs
274            ] = $this->migrateToProvider( $provider, $config, $dryRun );
275            $this->output( count( $missingConfigs ) . " missing config options:\n" );
276            $this->output( implode( "\n", array_values( $missingConfigs ) ) . "\n\n" );
277            $this->output( count( $migratedConfigs ) . " migrated config options:\n" );
278            $this->output( implode( "\n", array_values( $migratedConfigs ) ) . "\n\n" );
279            $allMigratedConfigs = array_merge( $allMigratedConfigs, $migratedConfigs );
280        }
281
282        $untouchedConfigs = array_diff( array_keys( $config ), $allMigratedConfigs );
283        $this->output( count( $untouchedConfigs ) . " untouched config options:\n" );
284        $this->output( implode( "\n", array_values( $untouchedConfigs ) ) . "\n\n" );
285
286        return !$dryRun;
287    }
288
289    /**
290     * @inheritDoc
291     */
292    protected function getUpdateKey() {
293        return 'GrowthExperimentsMigrateCommunityConfig';
294    }
295
296    /**
297     * Load the GEWikiConfigPageTitle configured page
298     * @return array|StatusValue The content of the configuration page (as JSON
299     *   data in PHP-native format), or a StatusValue on error.
300     * @throws MalformedTitleException
301     */
302    private function getGrowthExperimentsCommunityConfig( string $titleText ) {
303        $title = $this->titleFactory->newFromTextThrow( $titleText );
304        return $this->wikiPageConfigLoader->load( $title, IDBAccessObject::READ_LATEST );
305    }
306
307    /**
308     * @param StatusValue $storeStatus
309     * @return bool
310     */
311    private function hasValidationError( StatusValue $storeStatus ): bool {
312        return array_reduce( $storeStatus->getMessages(), static function ( $carry, $item ) {
313            return $carry || $item->getKey() === 'communityconfiguration-schema-validation-error';
314        }, false );
315    }
316}
317
318$maintClass = MigrateCommunityConfig::class;
319require_once RUN_MAINTENANCE_IF_MAIN;