jenkins-bot has submitted this change and it was merged. ( https://gerrit.wikimedia.org/r/326919 )
Change subject: Hygiene: Organize reducers ...................................................................... Hygiene: Organize reducers Reducer changes: * Break the mw.popups.reducers.rootReducer test into @@INIT tests for each reducer as there's shouldn't be any need to test that a framework works as documented. Changes: * Move the mw.popups.reducers#preview, #eventLogging, and #settings reducers into their own files in resources/ext.popups/reducers/. * Make the associated tests mirror the new layout. * Move the initialization of the mw.popups.reducers namespace into its own file. * Remove the mw.popups.rootReducer property in favour of a simple private factory function per the reducer change above. Change-Id: I94229d9ef1985f3806eec44c2e8234a5bbddc94f --- M extension.json M resources/ext.popups/boot.js D resources/ext.popups/reducers.js A resources/ext.popups/reducers/eventLogging.js A resources/ext.popups/reducers/index.js A resources/ext.popups/reducers/preview.js A resources/ext.popups/reducers/settings.js R tests/qunit/ext.popups/reducers/eventLogging.test.js R tests/qunit/ext.popups/reducers/preview.test.js A tests/qunit/ext.popups/reducers/settings.test.js 10 files changed, 353 insertions(+), 325 deletions(-) Approvals: Jhernandez: Looks good to me, approved jenkins-bot: Verified diff --git a/extension.json b/extension.json index bf05854..52b8d92 100644 --- a/extension.json +++ b/extension.json @@ -65,17 +65,24 @@ "resources/ext.popups/processLinks.js", "resources/ext.popups/counts.js", "resources/ext.popups/gateway.js", - "resources/ext.popups/reducers.js", - "resources/ext.popups/changeListener.js", "resources/ext.popups/renderer.js", "resources/ext.popups/schema.js", + + "resources/ext.popups/reducers/index.js", + "resources/ext.popups/reducers/preview.js", + "resources/ext.popups/reducers/eventLogging.js", + "resources/ext.popups/reducers/settings.js", + + "resources/ext.popups/changeListener.js", "resources/ext.popups/changeListeners/footerLink.js", "resources/ext.popups/changeListeners/linkTitle.js", "resources/ext.popups/changeListeners/render.js", "resources/ext.popups/changeListeners/eventLogging.js", "resources/ext.popups/changeListeners/previewCount.js", "resources/ext.popups/changeListeners/settings.js", + "resources/ext.popups/settingsDialog.js", + "resources/ext.popups/boot.js" ], "templates": { diff --git a/resources/ext.popups/boot.js b/resources/ext.popups/boot.js index 5c8ae30..4767055 100644 --- a/resources/ext.popups/boot.js +++ b/resources/ext.popups/boot.js @@ -59,14 +59,13 @@ } /** - * Root reducer for all actions + * Creates the reducer for all actions. * - * @param {Object} global state before action - * @param {Object} action Redux action that modified state. - * Must have `type` property. - * @return {Object} global state after action + * @return {Redux.Reducer} */ - mw.popups.reducers.rootReducer = Redux.combineReducers( mw.popups.reducers ); + function createRootReducer() { + return Redux.combineReducers( mw.popups.reducers ); + } /* * Initialize the application by: @@ -102,7 +101,7 @@ } store = Redux.createStore( - mw.popups.reducers.rootReducer, + createRootReducer(), compose( Redux.applyMiddleware( ReduxThunk.default ) ) diff --git a/resources/ext.popups/reducers.js b/resources/ext.popups/reducers.js deleted file mode 100644 index 6f1bf39..0000000 --- a/resources/ext.popups/reducers.js +++ /dev/null @@ -1,264 +0,0 @@ -( function ( mw ) { - - // Sugar for the mw.popups.reducers.eventLogging reducer. - var counts = mw.popups.counts; - - mw.popups.reducers = {}; - - /** - * Creates the next state tree from the current state tree and some updates. - * - * N.B. OO.copy doesn't copy Element instances, whereas $.extend does. - * However, OO.copy does copy properties whose values are undefined or null, - * whereas $.extend doesn't. Since the state tree contains an Element instance - * - the preview.activeLink property - and we want to copy undefined/null into - * the state we need to manually iterate over updates and check with - * hasOwnProperty to copy over to the new state. - * - * In [change listeners](/doc/change_listeners.md), for example, we talk about - * the previous state and the current state (the `prevState` and `state` - * parameters, respectively). Since - * [reducers](http://redux.js.org/docs/basics/Reducers.html) take the current - * state and an action and make updates, "next state" seems appropriate. - * - * @param {Object} state - * @param {Object} updates - * @return {Object} - */ - function nextState( state, updates ) { - var result = {}, - key; - - for ( key in state ) { - if ( state.hasOwnProperty( key ) && !updates.hasOwnProperty( key ) ) { - result[key] = state[key]; - } - } - - for ( key in updates ) { - if ( updates.hasOwnProperty( key ) ) { - result[key] = updates[key]; - } - } - - return result; - } - - /** - * Reducer for actions that modify the state of the preview model - * - * @param {Object} state before action - * @param {Object} action Redux action that modified state. - * Must have `type` property. - * @return {Object} state after action - */ - mw.popups.reducers.preview = function ( state, action ) { - if ( state === undefined ) { - state = { - enabled: undefined, - activeLink: undefined, - activeEvent: undefined, - shouldShow: false, - isUserDwelling: false - }; - } - - switch ( action.type ) { - case mw.popups.actionTypes.BOOT: - return nextState( state, { - enabled: action.user.isInCondition - } ); - case mw.popups.actionTypes.LINK_DWELL: - return nextState( state, { - activeLink: action.el, - activeEvent: action.event, - - // When the user dwells on a link with their keyboard, a preview is - // renderered, and then dwells on another link, the LINK_ABANDON_END - // action will be ignored. - // - // Ensure that all the preview is hidden. - shouldShow: false - } ); - case mw.popups.actionTypes.LINK_ABANDON_END: - if ( action.el !== state.activeLink ) { - return state; - } - - /* falls through */ - case mw.popups.actionTypes.PREVIEW_ABANDON_END: - if ( !state.isUserDwelling ) { - return nextState( state, { - activeLink: undefined, - activeEvent: undefined, - fetchResponse: undefined, - shouldShow: false - } ); - } - - return state; - - case mw.popups.actionTypes.PREVIEW_DWELL: - return nextState( state, { - isUserDwelling: true - } ); - case mw.popups.actionTypes.PREVIEW_ABANDON_START: - return nextState( state, { - isUserDwelling: false - } ); - case mw.popups.actionTypes.FETCH_START: - return nextState( state, { - fetchResponse: undefined - } ); - case mw.popups.actionTypes.FETCH_END: - if ( action.el === state.activeLink ) { - return nextState( state, { - fetchResponse: action.result, - shouldShow: true - } ); - } - - /* falls through */ - default: - return state; - } - }; - - /** - * Reducer for actions that may result in an event being logged via Event - * Logging. - * - * The base data represents data that's shared between all events logged with - * the Popups schema ("Popups events"). Very nearly all of it is initialized - * during the BOOT action and doesn't change between link interactions, e.g. - * the user being an anon or the number of edits they've made. - * - * The user's number of previews, however, does change between link - * interactions and the associated bucket (a computed property) is what is - * logged. This is reflected in the state tree: the `previewCount` property is - * used to store the user's number of previews and the - * `baseData.previewCountBucket` property is used to store the associated - * bucket. - * - * @param {Object} state - * @param {Object} action - * @return {Object} The state as a result of processing the action - */ - mw.popups.reducers.eventLogging = function ( state, action ) { - var nextCount; - - if ( state === undefined ) { - state = { - previewCount: undefined, - baseData: {}, - interaction: undefined, - event: undefined - }; - } - - switch ( action.type ) { - case mw.popups.actionTypes.BOOT: - return nextState( state, { - previewCount: action.user.previewCount, - baseData: { - pageTitleSource: action.page.title, - namespaceIdSource: action.page.namespaceID, - pageIdSource: action.page.id, - isAnon: action.user.isAnon, - popupEnabled: action.user.isInCondition, - pageToken: action.pageToken, - sessionToken: action.sessionToken, - editCountBucket: counts.getEditCountBucket( action.user.editCount ), - previewCountBucket: counts.getPreviewCountBucket( action.user.previewCount ) - }, - event: { - action: 'pageLoaded' - } - } ); - - case mw.popups.actionTypes.EVENT_LOGGED: - return nextState( state, { - event: undefined - } ); - - case mw.popups.actionTypes.PREVIEW_SHOW: - nextCount = state.previewCount + 1; - - return nextState( state, { - previewCount: nextCount, - baseData: nextState( state.baseData, { - previewCountBucket: counts.getPreviewCountBucket( nextCount ) - } ), - interaction: nextState( state.interaction, { - timeToPreviewShow: action.timestamp - state.interaction.started - } ) - } ); - - case mw.popups.actionTypes.LINK_DWELL: - return nextState( state, { - interaction: { - token: action.interactionToken, - started: action.timestamp - } - } ); - - case mw.popups.actionTypes.LINK_CLICK: - return nextState( state, { - event: { - action: 'opened', - linkInteractionToken: state.interaction.token, - totalInteractionTime: Math.round( action.timestamp - state.interaction.started ) - } - } ); - - case mw.popups.actionTypes.LINK_ABANDON_START: - case mw.popups.actionTypes.PREVIEW_ABANDON_START: - return nextState( state, { - interaction: nextState( state.interaction, { - finished: action.timestamp - } ) - } ); - - case mw.popups.actionTypes.LINK_ABANDON_END: - case mw.popups.actionTypes.PREVIEW_ABANDON_END: - return nextState( state, { - event: { - action: state.interaction.timeToPreviewShow ? 'dismissed' : 'dwelledButAbandoned', - linkInteractionToken: state.interaction.token, - totalInteractionTime: Math.round( state.interaction.finished - state.interaction.started ) - } - } ); - - default: - return state; - } - }; - - /** - * Reducer for actions that modify the state of the settings - * - * @param {Object} state - * @param {Object} action - * @return {Object} state after action - */ - mw.popups.reducers.settings = function ( state, action ) { - if ( state === undefined ) { - state = { - shouldShow: false - }; - } - - switch ( action.type ) { - case mw.popups.actionTypes.SETTINGS_SHOW: - return nextState( state, { - shouldShow: true - } ); - case mw.popups.actionTypes.SETTINGS_HIDE: - return nextState( state, { - shouldShow: false - } ); - default: - return state; - } - }; -}( mediaWiki ) ); diff --git a/resources/ext.popups/reducers/eventLogging.js b/resources/ext.popups/reducers/eventLogging.js new file mode 100644 index 0000000..2ce4bcd --- /dev/null +++ b/resources/ext.popups/reducers/eventLogging.js @@ -0,0 +1,113 @@ +( function ( popups, nextState ) { + + /** + * Reducer for actions that may result in an event being logged via Event + * Logging. + * + * The base data represents data that's shared between all events logged with + * the Popups schema ("Popups events"). Very nearly all of it is initialized + * during the BOOT action and doesn't change between link interactions, e.g. + * the user being an anon or the number of edits they've made. + * + * The user's number of previews, however, does change between link + * interactions and the associated bucket (a computed property) is what is + * logged. This is reflected in the state tree: the `previewCount` property is + * used to store the user's number of previews and the + * `baseData.previewCountBucket` property is used to store the associated + * bucket. + * + * @param {Object} state + * @param {Object} action + * @return {Object} The state as a result of processing the action + */ + popups.reducers.eventLogging = function ( state, action ) { + var nextCount; + + if ( state === undefined ) { + state = { + previewCount: undefined, + baseData: {}, + interaction: undefined, + event: undefined + }; + } + + switch ( action.type ) { + case popups.actionTypes.BOOT: + return nextState( state, { + previewCount: action.user.previewCount, + baseData: { + pageTitleSource: action.page.title, + namespaceIdSource: action.page.namespaceID, + pageIdSource: action.page.id, + isAnon: action.user.isAnon, + popupEnabled: action.user.isInCondition, + pageToken: action.pageToken, + sessionToken: action.sessionToken, + editCountBucket: popups.counts.getEditCountBucket( action.user.editCount ), + previewCountBucket: popups.counts.getPreviewCountBucket( action.user.previewCount ) + }, + event: { + action: 'pageLoaded' + } + } ); + + case popups.actionTypes.EVENT_LOGGED: + return nextState( state, { + event: undefined + } ); + + case popups.actionTypes.PREVIEW_SHOW: + nextCount = state.previewCount + 1; + + return nextState( state, { + previewCount: nextCount, + baseData: nextState( state.baseData, { + previewCountBucket: popups.counts.getPreviewCountBucket( nextCount ) + } ), + interaction: nextState( state.interaction, { + timeToPreviewShow: action.timestamp - state.interaction.started + } ) + } ); + + case popups.actionTypes.LINK_DWELL: + return nextState( state, { + interaction: { + token: action.interactionToken, + started: action.timestamp + } + } ); + + case popups.actionTypes.LINK_CLICK: + return nextState( state, { + event: { + action: 'opened', + linkInteractionToken: state.interaction.token, + totalInteractionTime: Math.round( action.timestamp - state.interaction.started ) + } + } ); + + case popups.actionTypes.LINK_ABANDON_START: + case popups.actionTypes.PREVIEW_ABANDON_START: + return nextState( state, { + interaction: nextState( state.interaction, { + finished: action.timestamp + } ) + } ); + + case popups.actionTypes.LINK_ABANDON_END: + case popups.actionTypes.PREVIEW_ABANDON_END: + return nextState( state, { + event: { + action: state.interaction.timeToPreviewShow ? 'dismissed' : 'dwelledButAbandoned', + linkInteractionToken: state.interaction.token, + totalInteractionTime: Math.round( state.interaction.finished - state.interaction.started ) + } + } ); + + default: + return state; + } + }; + +}( mediaWiki.popups, mediaWiki.popups.nextState ) ); diff --git a/resources/ext.popups/reducers/index.js b/resources/ext.popups/reducers/index.js new file mode 100644 index 0000000..82f814e --- /dev/null +++ b/resources/ext.popups/reducers/index.js @@ -0,0 +1,44 @@ +( function ( popups ) { + + popups.reducers = {}; + + /** + * Creates the next state tree from the current state tree and some updates. + * + * N.B. OO.copy doesn't copy Element instances, whereas $.extend does. + * However, OO.copy does copy properties whose values are undefined or null, + * whereas $.extend doesn't. Since the state tree contains an Element instance + * - the preview.activeLink property - and we want to copy undefined/null into + * the state we need to manually iterate over updates and check with + * hasOwnProperty to copy over to the new state. + * + * In [change listeners](/doc/change_listeners.md), for example, we talk about + * the previous state and the current state (the `prevState` and `state` + * parameters, respectively). Since + * [reducers](http://redux.js.org/docs/basics/Reducers.html) take the current + * state and an action and make updates, "next state" seems appropriate. + * + * @param {Object} state + * @param {Object} updates + * @return {Object} + */ + popups.nextState = function ( state, updates ) { + var result = {}, + key; + + for ( key in state ) { + if ( state.hasOwnProperty( key ) && !updates.hasOwnProperty( key ) ) { + result[key] = state[key]; + } + } + + for ( key in updates ) { + if ( updates.hasOwnProperty( key ) ) { + result[key] = updates[key]; + } + } + + return result; + }; + +}( mediaWiki.popups ) ); diff --git a/resources/ext.popups/reducers/preview.js b/resources/ext.popups/reducers/preview.js new file mode 100644 index 0000000..dc5686b --- /dev/null +++ b/resources/ext.popups/reducers/preview.js @@ -0,0 +1,83 @@ +( function ( popups, nextState ) { + + /** + * Reducer for actions that modify the state of the preview model + * + * @param {Object} state before action + * @param {Object} action Redux action that modified state. + * Must have `type` property. + * @return {Object} state after action + */ + popups.reducers.preview = function ( state, action ) { + if ( state === undefined ) { + state = { + enabled: undefined, + activeLink: undefined, + activeEvent: undefined, + shouldShow: false, + isUserDwelling: false + }; + } + + switch ( action.type ) { + case popups.actionTypes.BOOT: + return nextState( state, { + enabled: action.user.isInCondition + } ); + case popups.actionTypes.LINK_DWELL: + return nextState( state, { + activeLink: action.el, + activeEvent: action.event, + + // When the user dwells on a link with their keyboard, a preview is + // renderered, and then dwells on another link, the LINK_ABANDON_END + // action will be ignored. + // + // Ensure that all the preview is hidden. + shouldShow: false + } ); + case popups.actionTypes.LINK_ABANDON_END: + if ( action.el !== state.activeLink ) { + return state; + } + + /* falls through */ + case popups.actionTypes.PREVIEW_ABANDON_END: + if ( !state.isUserDwelling ) { + return nextState( state, { + activeLink: undefined, + activeEvent: undefined, + fetchResponse: undefined, + shouldShow: false + } ); + } + + return state; + + case popups.actionTypes.PREVIEW_DWELL: + return nextState( state, { + isUserDwelling: true + } ); + case popups.actionTypes.PREVIEW_ABANDON_START: + return nextState( state, { + isUserDwelling: false + } ); + case popups.actionTypes.FETCH_START: + return nextState( state, { + fetchResponse: undefined + } ); + case popups.actionTypes.FETCH_END: + if ( action.el === state.activeLink ) { + return nextState( state, { + fetchResponse: action.result, + shouldShow: true + } ); + } + + /* falls through */ + default: + return state; + } + }; + +}( mediaWiki.popups, mediaWiki.popups.nextState ) ); diff --git a/resources/ext.popups/reducers/settings.js b/resources/ext.popups/reducers/settings.js new file mode 100644 index 0000000..615b985 --- /dev/null +++ b/resources/ext.popups/reducers/settings.js @@ -0,0 +1,31 @@ +( function ( popups, nextState ) { + + /** + * Reducer for actions that modify the state of the settings + * + * @param {Object} state + * @param {Object} action + * @return {Object} state after action + */ + popups.reducers.settings = function ( state, action ) { + if ( state === undefined ) { + state = { + shouldShow: false + }; + } + + switch ( action.type ) { + case popups.actionTypes.SETTINGS_SHOW: + return nextState( state, { + shouldShow: true + } ); + case popups.actionTypes.SETTINGS_HIDE: + return nextState( state, { + shouldShow: false + } ); + default: + return state; + } + }; + +}( mediaWiki.popups, mediaWiki.popups.nextState ) ); diff --git a/tests/qunit/ext.popups/reducers.eventLogging.test.js b/tests/qunit/ext.popups/reducers/eventLogging.test.js similarity index 94% rename from tests/qunit/ext.popups/reducers.eventLogging.test.js rename to tests/qunit/ext.popups/reducers/eventLogging.test.js index bf2abc7..8ea8ad1 100644 --- a/tests/qunit/ext.popups/reducers.eventLogging.test.js +++ b/tests/qunit/ext.popups/reducers/eventLogging.test.js @@ -10,6 +10,18 @@ } } ); + QUnit.test( '@@INIT', function ( assert ) { + assert.deepEqual( + this.initialState, + { + previewCount: undefined, + baseData: {}, + event: undefined, + interaction: undefined + } + ); + } ); + QUnit.test( 'BOOT', function ( assert ) { var action = { type: 'BOOT', diff --git a/tests/qunit/ext.popups/reducers.test.js b/tests/qunit/ext.popups/reducers/preview.test.js similarity index 63% rename from tests/qunit/ext.popups/reducers.test.js rename to tests/qunit/ext.popups/reducers/preview.test.js index 98b1e23..99b290d 100644 --- a/tests/qunit/ext.popups/reducers.test.js +++ b/tests/qunit/ext.popups/reducers/preview.test.js @@ -1,41 +1,29 @@ ( function ( mw, $ ) { - QUnit.module( 'ext.popups/reducers', { + QUnit.module( 'ext.popups/reducers#preview', { setup: function () { this.el = $( '<a>' ); } } ); - QUnit.test( '#rootReducer', function ( assert ) { - var state = mw.popups.reducers.rootReducer( undefined, { type: '@@INIT' } ); + QUnit.test( '@@INIT', function ( assert ) { + var state = mw.popups.reducers.preview( undefined, { type: '@@INIT' } ); assert.expect( 1 ); assert.deepEqual( state, { - preview: { - enabled: undefined, - activeLink: undefined, - activeEvent: undefined, - shouldShow: false, - isUserDwelling: false - }, - eventLogging: { - previewCount: undefined, - baseData: {}, - event: undefined, - interaction: undefined - }, - settings: { - shouldShow: false - } - }, - 'It should initialize the state by default' + enabled: undefined, + activeLink: undefined, + activeEvent: undefined, + shouldShow: false, + isUserDwelling: false + } ); } ); - QUnit.test( '#preview: BOOT', function ( assert ) { + QUnit.test( 'BOOT', function ( assert ) { var action = { type: 'BOOT', user: { @@ -54,7 +42,7 @@ ); } ); - QUnit.test( '#preview: LINK_DWELL', function ( assert ) { + QUnit.test( 'LINK_DWELL', function ( assert ) { var action = { type: 'LINK_DWELL', el: this.el, @@ -72,7 +60,7 @@ ); } ); - QUnit.test( '#preview: LINK_ABANDON_END', function ( assert ) { + QUnit.test( 'LINK_ABANDON_END', function ( assert ) { var action = { type: 'LINK_ABANDON_END', el: this.el @@ -106,7 +94,7 @@ ); } ); - QUnit.test( '#preview: FETCH_END', function ( assert ) { + QUnit.test( 'FETCH_END', function ( assert ) { var state = { activeLink: this.el }, @@ -147,7 +135,7 @@ ); } ); - QUnit.test( '#preview: PREVIEW_DWELL', function ( assert ) { + QUnit.test( 'PREVIEW_DWELL', function ( assert ) { var action = { type: 'PREVIEW_DWELL' }; @@ -163,7 +151,7 @@ ); } ); - QUnit.test( '#preview: PREVIEW_ABANDON_START', function ( assert ) { + QUnit.test( 'PREVIEW_ABANDON_START', function ( assert ) { var action = { type: 'PREVIEW_ABANDON_START' }; @@ -177,29 +165,4 @@ ); } ); - QUnit.test( '#settings: SETTINGS_SHOW', function ( assert ) { - assert.expect( 1 ); - - assert.deepEqual( - mw.popups.reducers.settings( {}, { type: 'SETTINGS_SHOW' } ), - { - shouldShow: true - }, - 'It should mark the settings dialog as ready to be shown' - ); - } ); - - QUnit.test( '#settings: SETTINGS_HIDE', function ( assert ) { - assert.expect( 1 ); - - assert.deepEqual( - mw.popups.reducers.settings( {}, { type: 'SETTINGS_HIDE' } ), - { - shouldShow: false - }, - 'It should mark the settings dialog as ready to be closed' - ); - } ); - }( mediaWiki, jQuery ) ); - diff --git a/tests/qunit/ext.popups/reducers/settings.test.js b/tests/qunit/ext.popups/reducers/settings.test.js new file mode 100644 index 0000000..7faf3ad --- /dev/null +++ b/tests/qunit/ext.popups/reducers/settings.test.js @@ -0,0 +1,40 @@ +( function ( mw ) { + + QUnit.module( 'ext.popups/reducers#settings' ); + + QUnit.test( '@@INIT', function ( assert ) { + var state = mw.popups.reducers.settings( undefined, { type: '@@INIT' } ); + + assert.deepEqual( + state, + { + shouldShow: false + } + ); + } ); + + QUnit.test( 'SETTINGS_SHOW', function ( assert ) { + assert.expect( 1 ); + + assert.deepEqual( + mw.popups.reducers.settings( {}, { type: 'SETTINGS_SHOW' } ), + { + shouldShow: true + }, + 'It should mark the settings dialog as ready to be shown.' + ); + } ); + + QUnit.test( 'SETTINGS_HIDE', function ( assert ) { + assert.expect( 1 ); + + assert.deepEqual( + mw.popups.reducers.settings( {}, { type: 'SETTINGS_HIDE' } ), + { + shouldShow: false + }, + 'It should mark the settings dialog as ready to be closed.' + ); + } ); + +}( mediaWiki ) ); -- To view, visit https://gerrit.wikimedia.org/r/326919 To unsubscribe, visit https://gerrit.wikimedia.org/r/settings Gerrit-MessageType: merged Gerrit-Change-Id: I94229d9ef1985f3806eec44c2e8234a5bbddc94f Gerrit-PatchSet: 2 Gerrit-Project: mediawiki/extensions/Popups Gerrit-Branch: mpga Gerrit-Owner: Phuedx <samsm...@wikimedia.org> Gerrit-Reviewer: Jhernandez <jhernan...@wikimedia.org> Gerrit-Reviewer: Phuedx <samsm...@wikimedia.org> Gerrit-Reviewer: jenkins-bot <> _______________________________________________ MediaWiki-commits mailing list MediaWiki-commits@lists.wikimedia.org https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits