Pwirth has submitted this change and it was merged.

Change subject: API base classes: ExtJS store and Tasks
......................................................................


API base classes: ExtJS store and Tasks

This change contains some refactoring of class names and also changes
internal implemenation details! This introduces a potential breaking
change for subclasses. But as those classes are not used very much
this should not be a problem.

The goal is to provide a common API infrastructure within BlueSpice.

* Also added a CSS fix for CRUDGridPanels when a grouping feature is being
used
* Added more dataset processing logic to ExtJSStore base class
* Created abstract 'makeData' and implemented 'execute' to have even less
code in the subclass
* Added some generic hooks
* Added standardized taskData for Tasks API

Change-Id: Id8e1ad8bd2a14e9f8bddbc5c6873b1522d294b31
---
M includes/AutoLoader.php
D includes/api/ApiExtJSBase.php
A includes/api/BSApiBase.php
A includes/api/BSApiExtJSStoreBase.php
R includes/api/BSApiTasksBase.php
A includes/api/BSStandardAPIResponse.php
M includes/utility/CacheHelper.class.php
M resources/bluespice.extjs/bluespice.extjs.fixes.css
8 files changed, 396 insertions(+), 138 deletions(-)

Approvals:
  Pwirth: Verified; Looks good to me, approved



diff --git a/includes/AutoLoader.php b/includes/AutoLoader.php
index 59f6263..ffadfb5 100644
--- a/includes/AutoLoader.php
+++ b/includes/AutoLoader.php
@@ -51,7 +51,10 @@
 $GLOBALS['wgAutoloadClasses']['BsExtJSSortParam'] = 
__DIR__."/ExtJSStoreParams.php";
 
 //api
-$GLOBALS['wgAutoloadClasses']['BsApiBase'] = __DIR__."/api/BsApiBase.php";
+$GLOBALS['wgAutoloadClasses']['BSStandardAPIResponse'] = 
__DIR__."/api/BSStandardAPIResponse.php";
+$GLOBALS['wgAutoloadClasses']['BSApiBase'] = __DIR__."/api/BSApiBase.php";
+$GLOBALS['wgAutoloadClasses']['BSApiTasksBase'] = 
__DIR__."/api/BSApiTasksBase.php";
+$GLOBALS['wgAutoloadClasses']['BSApiExtJSStoreBase'] = 
__DIR__."/api/BSApiExtJSStoreBase.php";
 
 //adapter
 $GLOBALS['wgAutoloadClasses']['BsExtensionMW'] = 
__DIR__."/ExtensionMW.class.php";
diff --git a/includes/api/ApiExtJSBase.php b/includes/api/ApiExtJSBase.php
deleted file mode 100644
index 90546f7..0000000
--- a/includes/api/ApiExtJSBase.php
+++ /dev/null
@@ -1,69 +0,0 @@
-<?php
-
-abstract class ApiExtJSBase extends ApiBase {
-
-       public function getAllowedParams() {
-               return array(
-                       'sort' => array(
-                               ApiBase::PARAM_TYPE => 'string',
-                               ApiBase::PARAM_REQUIRED => false
-                       ),
-                       'page' => array(
-                               ApiBase::PARAM_TYPE => 'integer',
-                               ApiBase::PARAM_REQUIRED => false
-                       ),
-                       'limit' => array(
-                               ApiBase::PARAM_TYPE => 'integer',
-                               ApiBase::PARAM_REQUIRED => false
-                       ),
-                       'start' => array(
-                               ApiBase::PARAM_TYPE => 'integer',
-                               ApiBase::PARAM_REQUIRED => false
-                       ),
-                       '_dc' => array(
-                               ApiBase::PARAM_TYPE => 'integer',
-                               ApiBase::PARAM_REQUIRED => false
-                       ),
-                       'format' => array(
-                               ApiBase::PARAM_DFLT => 'json',
-                               ApiBase::PARAM_TYPE => array( 'json', 'jsonfm' 
),
-                       )
-               );
-       }
-
-       public function getParamDescription() {
-               return array(
-                       'sort' => 'JSON string with sorting info',
-                       'page' => 'Allows server side calculation of 
start/limit',
-                       'limit' => 'Number of results to return',
-                       'start' => 'The offset to start the result list from',
-                       '_dc' => '"Disable cache" flag',
-                       'format' => 'The format of the output'
-               );
-       }
-
-       public function getResultProperties() {
-               return array(
-                       '' => array(
-                               'success' => 'boolean',
-                               'results' => array(
-                                       ApiBase::PROP_TYPE => 'integer',
-                                       ApiBase::PROP_NULLABLE => false
-                               ),
-                               'rows' => array(
-                                       ApiBase::PROP_TYPE => 'array',
-                                       ApiBase::PROP_NULLABLE => false
-                               )
-                       )
-               );
-       }
-
-       protected function getParameterFromSettings($paramName, $paramSettings, 
$parseLimit) {
-               $value = parent::getParameterFromSettings($paramName, 
$paramSettings, $parseLimit);
-               //Unfortunately there is no way to register custom types for 
parameters
-               if( $paramName === 'sort' ) {
-                       $value = FormatJson::decode($value);
-               }
-               return $value;
-       }
-}
\ No newline at end of file
diff --git a/includes/api/BSApiBase.php b/includes/api/BSApiBase.php
new file mode 100644
index 0000000..3646340
--- /dev/null
+++ b/includes/api/BSApiBase.php
@@ -0,0 +1,50 @@
+<?php
+
+abstract class BSApiBase extends ApiBase {
+       /**
+        * Checks access permissions based on a list of titles and permissions. 
If
+        * one of it fails the API processing is ended with an appropriate 
message
+        * @param array $aTitles Array of Title objects to check the requires 
permissions against
+        * @param User $oUser the User object of the requesting user. Does a 
fallback to $this->getUser();
+        */
+       protected function checkPermissions( $aTitles = array(), $oUser = null 
) {
+               $aRequiredPermissions = $this->getRequiredPermissions();
+               if( empty( $aRequiredPermissions ) ) {
+                       return; //No need for further checking
+               }
+               foreach( $aTitles as $oTitle ) {
+                       if( $oTitle instanceof Title === false ) {
+                               continue;
+                       }
+                       foreach( $aRequiredPermissions as $sPermission ) {
+                               if( $oTitle->userCan( $sPermission ) === false 
) {
+                                       //TODO: Reflect title and permission in 
error message
+                                       $this->dieUsageMsg( 'badaccess-groups' 
);
+                               }
+                       }
+               }
+
+               //Fallback if not conrete title was provided
+               if( empty( $aTitles ) ) {
+                       if( $oUser instanceof User === false ) {
+                               $oUser = $this->getUser();
+                       }
+                       foreach( $aRequiredPermissions as $sPermission ) {
+                               if( $oUser->isAllowed( $sPermission ) === false 
) {
+                                       //TODO: Reflect permission in error 
message
+                                       $this->dieUsageMsg( 'badaccess-groups' 
);
+                               }
+                       }
+               }
+       }
+
+       protected function getRequiredPermissions() {
+               return array( 'read' );
+       }
+
+       protected function getExamples() {
+               return array(
+                       'api.php?action='.$this->getModuleName(),
+               );
+       }
+}
\ No newline at end of file
diff --git a/includes/api/BSApiExtJSStoreBase.php 
b/includes/api/BSApiExtJSStoreBase.php
new file mode 100644
index 0000000..9e40541
--- /dev/null
+++ b/includes/api/BSApiExtJSStoreBase.php
@@ -0,0 +1,281 @@
+<?php
+
+/**
+ *  This class serves as a backend for ExtJS stores. It allows all
+ * necessary parameters and provides convenience methods and a standard ouput
+ * format
+ *
+ * Example request parameters of an ExtJS store
+
+       _dc:1430126252980
+       filter:[
+               {
+                       "type":"string",
+                       "comparison":"ct",
+                       "value":"some text ...",
+                       "field":"someField"
+               }
+       ]
+       group:[
+               {
+                       "property":"someOtherField",
+                       "direction":"ASC"
+               }
+       ]
+       sort:[
+               {
+                       "property":"someOtherField",
+                       "direction":"ASC"
+               }
+       ]
+       page:1
+       start:0
+       limit:25
+ */
+abstract class BSApiExtJSStoreBase extends BSApiBase {
+
+       /**
+        * The current parameters sent by the ExtJS store
+        * @var BsExtJSStoreParams
+        */
+       protected $oStoreParams = null;
+
+       /**
+        * Automatically set within 'postProcessData' method
+        * @var int
+        */
+       protected $iFinalDataSetCount = 0;
+
+       /**
+        * May be overwritten by subclass
+        * @var string
+        */
+       protected $root = 'results';
+
+       /**
+        * May be overwritten by subclass
+        * @var string
+        */
+       protected $totalProperty = 'total';
+
+       public function execute() {
+               $aData = $this->makeData();
+               $FinalData = $this->postProcessData( $aData );
+               $this->returnData( $FinalData );
+       }
+
+       protected abstract function makeData();
+
+       /**
+        * Creates a proper output format based on the classes properties
+        * @param array $aData An array of plain old data objects
+        */
+       public function returnData($aData) {
+               wfRunHooks( 'BSApiExtJSStoreBaseBeforeReturnData', array( 
$this, &$aData ) );
+               $result = $this->getResult();
+               $result->setIndexedTagName( $aData, $this->root );
+               $result->addValue( null, $this->root, $aData );
+               $result->addValue( null, $this->totalProperty, 
$this->iFinalDataSetCount );
+       }
+
+       /**
+        *
+        * @return BsExtJSStoreParams
+        */
+       protected function getStoreParams() {
+               if( $this->oStoreParams === null ) {
+                       $this->oStoreParams = 
BsExtJSStoreParams::newFromRequest();
+               }
+               return $this->oStoreParams;
+       }
+
+       public function getAllowedParams() {
+               return array(
+                       'sort' => array(
+                               ApiBase::PARAM_TYPE => 'string',
+                               ApiBase::PARAM_REQUIRED => false,
+                               ApiBase::PARAM_DFLT => '[]'
+                       ),
+                       'group' => array(
+                               ApiBase::PARAM_TYPE => 'string',
+                               ApiBase::PARAM_REQUIRED => false,
+                               ApiBase::PARAM_DFLT => '[]'
+                       ),
+                       'filter' => array(
+                               ApiBase::PARAM_TYPE => 'string',
+                               ApiBase::PARAM_REQUIRED => false,
+                               ApiBase::PARAM_DFLT => '[]'
+                       ),
+                       'page' => array(
+                               ApiBase::PARAM_TYPE => 'integer',
+                               ApiBase::PARAM_REQUIRED => false,
+                               ApiBase::PARAM_DFLT => 0
+                       ),
+                       'limit' => array(
+                               ApiBase::PARAM_TYPE => 'integer',
+                               ApiBase::PARAM_REQUIRED => false,
+                               ApiBase::PARAM_DFLT => 25
+                       ),
+                       'start' => array(
+                               ApiBase::PARAM_TYPE => 'integer',
+                               ApiBase::PARAM_REQUIRED => false,
+                               ApiBase::PARAM_DFLT => 0
+                       ),
+
+                       'callback' => array(
+                               ApiBase::PARAM_TYPE => 'string',
+                               ApiBase::PARAM_REQUIRED => false
+                       ),
+
+                       'query' => array(
+                               ApiBase::PARAM_TYPE => 'string',
+                               ApiBase::PARAM_REQUIRED => false
+                       ),
+                       '_dc' => array(
+                               ApiBase::PARAM_TYPE => 'integer',
+                               ApiBase::PARAM_REQUIRED => false
+                       ),
+                       'format' => array(
+                               ApiBase::PARAM_DFLT => 'json',
+                               ApiBase::PARAM_TYPE => array( 'json', 'jsonfm' )
+                       )
+               );
+       }
+
+       public function getParamDescription() {
+               return array(
+                       'sort' => 'JSON string with sorting info; deserializes 
to array of objects that hold filed name and direction for each sorting option',
+                       'group' => 'JSON string with grouping info; 
deserializes to array of objects that hold filed name and direction for each 
grouping option',
+                       'filter' => 'JSON string with filter info; deserializes 
to array of objects that hold filed name, filter type, and filter value for 
each sorting option',
+                       'page' => 'Allows server side calculation of 
start/limit',
+                       'limit' => 'Number of results to return',
+                       'start' => 'The offset to start the result list from',
+                       'query' => 'This is similar to "filter", but the 
provided value serves as a filter only for the "value" field of an ExtJS 
component',
+                       'callback' => 'The offset to start the result list 
from',
+                       '_dc' => '"Disable cache" flag',
+                       'format' => 'The format of the output (only JSON or 
formatted JSON)'
+               );
+       }
+
+       protected function getParameterFromSettings($paramName, $paramSettings, 
$parseLimit) {
+               $value = parent::getParameterFromSettings($paramName, 
$paramSettings, $parseLimit);
+               //Unfortunately there is no way to register custom types for 
parameters
+               if( in_array( $paramName, array( 'sort', 'group', 'filter' ) ) 
) {
+                       $value = FormatJson::decode($value);
+                       if( empty($value) ) {
+                               return array();
+                       }
+               }
+               return $value;
+       }
+
+       /**
+        * Filter, sort and trim the result according to the call parameters and
+        * apply security trimming
+        * @param array $aData
+        * @return array
+        */
+       public function postProcessData( $aData ) {
+               wfRunHooks( 'BSApiExtJSStoreBaseBeforePostProcessData', array( 
$this, &$aData ) );
+               $aProcessedData = array();
+
+               //First, apply filter
+               $aProcessedData = array_filter($aData, array( $this, 
'filterCallback') );
+               wfRunHooks( 'BSApiExtJSStoreBaseAfterFilterData', array( $this, 
&$aProcessedData ) );
+
+               //Next, apply sort
+               usort($aProcessedData, array( $this, 'sortCallback') );
+
+               //Before we trim, we save the count
+               $this->iFinalDataSetCount = count( $aProcessedData );
+
+               //Last, do trimming
+               $aProcessedData = $this->trimData( $aProcessedData );
+
+               return $aProcessedData;
+       }
+
+       /**
+        * Applies all sorters provided by the store
+        * @param object $oA
+        * @param object $oB
+        * @return int
+        */
+       public function sortCallback( $oA, $oB ) {
+               $aSort = $this->getParameter('sort');
+               $iCount = count( $aSort );
+               for( $i = 0; $i < $iCount; $i++ ) {
+                       $sProperty = $aSort[$i]->property;
+                       $sDirection = strtoupper( $aSort[$i]->direction );
+
+                       if( $oA->$sProperty !== $oB->$sProperty ) {
+                               if( $sDirection === 'ASC' ) {
+                                       return $oA->$sProperty > 
$oB->$sProperty ? -1 : 1;
+                               }
+                               else { //'DESC'
+                                       return $oA->$sProperty < 
$oB->$sProperty ? -1 : 1;
+                               }
+                       }
+               }
+               return 0;
+       }
+
+       public function filterCallback( $aDataSet ) {
+               $aFilter = $this->getParameter('filter');
+               foreach( $aFilter as $oFilter ) {
+                       //If just one of these filters does not apply, the 
dataset needs
+                       //to be removed
+
+                       if( $oFilter->type == 'string' ) {
+                               $bFilterApplies = $this->filterString($oFilter, 
$aDataSet);
+                               if( !$bFilterApplies ) {
+                                       return false;
+                               }
+                       }
+                       //TODO: Implement for type 'date', 'datetime', 
'boolean' and 'numeric'
+                       //... and even maybe 'list'
+               }
+
+               return true;
+       }
+
+       public function filterString($oFilter, $aDataSet) {
+               $sFieldValue = $aDataSet->{$oFilter->field};
+               $sFilterValue = $oFilter->value;
+
+               //TODO: Add string functions to BsStringHelper
+               //HINT: http://stackoverflow.com/a/10473026 + Case insensitive
+               switch( $oFilter->comparison ) {
+                       case 'sw':
+                               return $sFilterValue === '' ||
+                                       strripos($sFieldValue, $sFilterValue, 
-strlen($sFieldValue)) !== false;
+                       case 'ew':
+                               return $sFilterValue === '' ||
+                                       (($temp = strlen($sFieldValue) - 
strlen($sFilterValue)) >= 0
+                                       && stripos($sFieldValue, $sFilterValue, 
$temp) !== false);
+                       case 'ct':
+                               return stripos($sFieldValue, $sFilterValue) !== 
false;
+                       case 'nct':
+                               return stripos($sFieldValue, $sFilterValue) === 
false;
+                       case 'eq':
+                               return $sFieldValue === $sFilterValue;
+                       case 'neq':
+                               return $sFieldValue !== $sFilterValue;
+               }
+       }
+
+       public function trimData($aProcessedData) {
+               $iStart = $this->getParameter('start');
+               $iEnd = $this->getParameter('limit') + $iStart;
+               if( $iEnd >= $this->iFinalDataSetCount ) {
+                       $iEnd = $this->iFinalDataSetCount - 1;
+               }
+
+               $aTrimmedData = array();
+               for( $i = $iStart; $i <= $iEnd; $i++ ) {
+                       $aTrimmedData[] = $aProcessedData[$i];
+               }
+
+               return $aTrimmedData;
+       }
+}
diff --git a/includes/api/BsApiBase.php b/includes/api/BSApiTasksBase.php
similarity index 62%
rename from includes/api/BsApiBase.php
rename to includes/api/BSApiTasksBase.php
index 6548962..986a7d3 100644
--- a/includes/api/BsApiBase.php
+++ b/includes/api/BSApiTasksBase.php
@@ -27,26 +27,16 @@
  */
 
 /**
- * Api base class for BlueSpice
+ * Api base class for simple tasks in BlueSpice
  * @package BlueSpice_Foundation
  */
-abstract class BsApiBase extends ApiBase {
+abstract class BSApiTasksBase extends BSApiBase {
 
        /**
         * Methods that can be called by task param
         * @var array
         */
-       protected static $aTasks = array();
-
-       /**
-        * Constructor
-        * @param $mainModule ApiMain object
-        * @param string $moduleName Name of this module
-        * @param string $modulePrefix Prefix to use for parameter names
-        */
-       public function __construct( $query, $moduleName, $modulePrefix = '' ) {
-               parent::__construct( $query, $moduleName, $modulePrefix );
-       }
+       protected $aTasks = array();
 
        /**
         * The execute() method will be invoked directly by ApiMain immediately
@@ -62,33 +52,31 @@
                //Avoid API warning: register the parameter used to bust 
browser cache
                $this->getMain()->getVal( '_' );
 
-               if( !isset($aParams['task']) ) return;
+               $sMethod = 'task_'.$aParams['task'];
 
-               if( in_array($aParams['task'], static::$aTasks) ) {
-                       $oResult = call_user_func(
-                               array($this, $aParams['task']),
-                               $aParams
-                       );
+               if( !is_callable( array( $this, $sMethod ) ) ) {
+                       $oResult = $this->makeStandardReturn();
+                       $oResult->errors['task'] = 'Task '.$aParams['task'].' 
not implemented';
+               }
+               else {
+                       $oResult = $this->$sMethod( 
$this->getParameter('taskData'), $aParams );
                }
 
-               $this->getResult()->addValue(null, 'bs', $oResult);
+               foreach( $oResult as $sFieldName => $mFieldValue ) {
+                       if( $mFieldValue === null ) {
+                               continue; //MW Api doesn't like NULL values
+                       }
+                       $this->getResult()->addValue(null, $sFieldName, 
$mFieldValue);
+               }
        }
 
        /**
         * Standard return object
         * Every task should return this!
-        * @return object
+        * @return BSStandardAPIResponse
         */
-       protected static function stdReturn() {
-               return $oReturn = (object) array(
-                       'result' => array(
-                               'payload' => null,
-                               'success' => false,
-                               'message' => '',
-                               'errors' => array(),
-                               'payload_count' => 0,
-                       )
-               );
+       protected function makeStandardReturn() {
+               return new BSStandardAPIResponse();
        }
 
        /**
@@ -99,13 +87,30 @@
                return array(
                        'task' => array(
                                ApiBase::PARAM_REQUIRED => true,
-                               ApiBase::PARAM_TYPE => 'string'
+                               ApiBase::PARAM_TYPE => $this->aTasks,
+                       ),
+                       'taskData' => array(
+                               ApiBase::PARAM_TYPE => 'string',
+                               ApiBase::PARAM_REQUIRED => false,
+                               ApiBase::PARAM_DFLT => '{}'
                        ),
                        'format' => array(
                                ApiBase::PARAM_DFLT => 'json',
                                ApiBase::PARAM_TYPE => array( 'json', 'jsonfm' 
),
                        )
                );
+       }
+
+       protected function getParameterFromSettings($paramName, $paramSettings, 
$parseLimit) {
+               $value = parent::getParameterFromSettings($paramName, 
$paramSettings, $parseLimit);
+               //Unfortunately there is no way to register custom types for 
parameters
+               if( $paramName === 'taskData' ) {
+                       $value = FormatJson::decode($value);
+                       if( empty($value) ) {
+                               return array();
+                       }
+               }
+               return $value;
        }
 
        /**
@@ -115,40 +120,9 @@
        public function getParamDescription() {
                return array(
                        'task' => 'The task you would like to execute',
+                       'taskData' => 'JSON string encoded object with 
arbitrary data for the task',
                        'format' => 'The format of the result',
                );
-       }
-
-       /**
-        * Default to false
-        * @return boolean
-        */
-       public function needsToken() {
-               return false;
-       }
-
-       /**
-        * Default to empty string
-        * @return string
-        */
-       public function getTokenSalt() {
-               return '';
-       }
-
-       /**
-        * Default to true
-        * @return boolean
-        */
-       public function mustBePosted() {
-               return true;
-       }
-
-       /**
-        * Default to false
-        * @return boolean
-        */
-       public function isWriteMode() {
-               return false;
        }
 
        /**
@@ -157,7 +131,7 @@
         */
        public function getDescription() {
                return array(
-                       'BsApiBase: This should be implemented by subclass'
+                       'BSApiTasksBase: This should be implemented by subclass'
                );
        }
 
@@ -167,7 +141,7 @@
         */
        public function getExamples() {
                return array(
-                       
'api.php?action=<childapimodule>&task=<taskofchildapimodule>',
+                       
'api.php?action='.$this->getModuleName().'&task='.$this->aTasks[0].'&taskData={someKey:"someValue",isFalse:true}',
                );
        }
 }
\ No newline at end of file
diff --git a/includes/api/BSStandardAPIResponse.php 
b/includes/api/BSStandardAPIResponse.php
new file mode 100644
index 0000000..83fd84f
--- /dev/null
+++ b/includes/api/BSStandardAPIResponse.php
@@ -0,0 +1,18 @@
+<?php
+
+/**
+ * This response should implement the ExtJS standard format for serverside
+ * form validations:
+ * http://docs.sencha.com/extjs/4.2.2/#!/api/Ext.form.action.Submit
+ *
+ * TODO: do a clean implemenation with gettes and setters
+ */
+class BSStandardAPIResponse {
+       public $errors = array(); //ExtJS
+       public $success = false; //ExtJS
+
+       //Custom fields
+       public $message = '';
+       public $payload = array();
+       public $payload_count = 0;
+}
\ No newline at end of file
diff --git a/includes/utility/CacheHelper.class.php 
b/includes/utility/CacheHelper.class.php
index 770c379..d5d89de 100644
--- a/includes/utility/CacheHelper.class.php
+++ b/includes/utility/CacheHelper.class.php
@@ -18,7 +18,7 @@
         */
        public static function getCache() {
                if ( self::$oCache === null ) {
-                       self::$oCache = wfGetCache( CACHE_ANYTHING );
+                       self::$oCache = wfGetMainCache();
                }
 
                return self::$oCache;
diff --git a/resources/bluespice.extjs/bluespice.extjs.fixes.css 
b/resources/bluespice.extjs/bluespice.extjs.fixes.css
index 3404c88..f69f67c 100644
--- a/resources/bluespice.extjs/bluespice.extjs.fixes.css
+++ b/resources/bluespice.extjs/bluespice.extjs.fixes.css
@@ -77,7 +77,8 @@
 .bs-extjs-crud-grid tr.x-grid-row:hover .bs-extjs-actioncolumn-icon {
        display: inline-block;
 }
-.bs-extjs-crud-grid tr.x-grid-row {
+.bs-extjs-crud-grid tr.x-grid-row,
+.bs-extjs-crud-grid tr.x-grid-data-row {
        height: 34px;
 }
 

-- 
To view, visit https://gerrit.wikimedia.org/r/206801
To unsubscribe, visit https://gerrit.wikimedia.org/r/settings

Gerrit-MessageType: merged
Gerrit-Change-Id: Id8e1ad8bd2a14e9f8bddbc5c6873b1522d294b31
Gerrit-PatchSet: 5
Gerrit-Project: mediawiki/extensions/BlueSpiceFoundation
Gerrit-Branch: master
Gerrit-Owner: Robert Vogel <vo...@hallowelt.biz>
Gerrit-Reviewer: Mglaser <gla...@hallowelt.biz>
Gerrit-Reviewer: Pwirth <wi...@hallowelt.biz>
Gerrit-Reviewer: Robert Vogel <vo...@hallowelt.biz>
Gerrit-Reviewer: Tweichart <weich...@hallowelt.biz>
Gerrit-Reviewer: jenkins-bot <>

_______________________________________________
MediaWiki-commits mailing list
MediaWiki-commits@lists.wikimedia.org
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits

Reply via email to