Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
77.52% |
100 / 129 |
|
57.14% |
4 / 7 |
CRAP | |
0.00% |
0 / 1 |
MigrateCommunityConfig | |
81.30% |
100 / 123 |
|
57.14% |
4 / 7 |
40.12 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
initServices | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
3 | |||
migrateToProvider | |
75.38% |
49 / 65 |
|
0.00% |
0 / 1 |
25.97 | |||
doDBUpdates | |
87.88% |
29 / 33 |
|
0.00% |
0 / 1 |
5.04 | |||
getUpdateKey | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getGrowthExperimentsCommunityConfig | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
hasValidationError | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 |
1 | <?php |
2 | |
3 | namespace GrowthExperiments\Maintenance; |
4 | |
5 | use GrowthExperiments\Config\MediaWikiConfigReaderWrapper; |
6 | use GrowthExperiments\Config\WikiPageConfigLoader; |
7 | use GrowthExperiments\GrowthExperimentsServices; |
8 | use MediaWiki\Config\Config; |
9 | use MediaWiki\Context\RequestContext; |
10 | use MediaWiki\Extension\CommunityConfiguration\CommunityConfigurationServices; |
11 | use MediaWiki\Extension\CommunityConfiguration\Provider\ConfigurationProviderFactory; |
12 | use MediaWiki\Extension\CommunityConfiguration\Provider\IConfigurationProvider; |
13 | use MediaWiki\Language\FormatterFactory; |
14 | use MediaWiki\Maintenance\LoggedUpdateMaintenance; |
15 | use MediaWiki\Permissions\UltimateAuthority; |
16 | use MediaWiki\Title\MalformedTitleException; |
17 | use MediaWiki\Title\TitleFactory; |
18 | use MediaWiki\User\User; |
19 | use StatusValue; |
20 | use Wikimedia\Rdbms\IDBAccessObject; |
21 | |
22 | $IP = getenv( 'MW_INSTALL_PATH' ); |
23 | if ( $IP === false ) { |
24 | $IP = __DIR__ . '/../../..'; |
25 | } |
26 | require_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 | */ |
38 | class 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; |
319 | require_once RUN_MAINTENANCE_IF_MAIN; |