jenkins-bot has submitted this change and it was merged.
Change subject: Add option to trust users
......................................................................
Add option to trust users
Provide a button in the Special:SmiteSpam interface to allow marking a
user as trusted. Pages created by trusted users do not appear in the
list of pages marked as spam.
* Adds an API module "smitespamtrustuser" to allow marking a user as
trusted.
* Adds a special page Special:SmiteSpamTrustedUsers to list the trusted
users and add or remove them.
* Requires running update.php
Change-Id: I4e2e14677f5b02613c2632a2ec9209bd750c8e7a
---
M SmiteSpam.alias.php
A SmiteSpam.hooks.php
M SmiteSpam.php
A SpecialSmiteSpamTrustedUsers.php
A api/SmiteSpamApiTrustUser.php
M autoload.php
M generate-autoloads.php
M i18n/en.json
M i18n/qqq.json
M includes/SmiteSpamAnalyzer.php
M includes/SmiteSpamWikiPage.php
A smitespam.sql
M static/js/ext.smitespam.js
13 files changed, 354 insertions(+), 36 deletions(-)
Approvals:
jan: Looks good to me, but someone else must approve
Polybuildr: Looks good to me, approved
Yaron Koren: Looks good to me, but someone else must approve
jenkins-bot: Verified
diff --git a/SmiteSpam.alias.php b/SmiteSpam.alias.php
index cbadf13..999ab02 100644
--- a/SmiteSpam.alias.php
+++ b/SmiteSpam.alias.php
@@ -12,4 +12,4 @@
/** English (English) */
$specialPageAliases['en'] = array(
'SmiteSpam' => array( 'SmiteSpam' ),
-);
\ No newline at end of file
+);
diff --git a/SmiteSpam.hooks.php b/SmiteSpam.hooks.php
new file mode 100644
index 0000000..a4c52db
--- /dev/null
+++ b/SmiteSpam.hooks.php
@@ -0,0 +1,10 @@
+<?php
+
+class SmiteSpamHooks {
+ // Schema updates for update.php
+ public static function createTables( DatabaseUpdater $updater ) {
+ $updater->addExtensionTable( 'smitespam_trusted_user',
+ __DIR__ . '/smitespam.sql' );
+ return true;
+ }
+}
diff --git a/SmiteSpam.php b/SmiteSpam.php
index 8b5841d..5a63909 100644
--- a/SmiteSpam.php
+++ b/SmiteSpam.php
@@ -20,11 +20,15 @@
$wgMessagesDirs['SmiteSpam'] = "$ssRoot/i18n";
$wgExtensionMessagesFiles['SmiteSpamAlias'] = "$ssRoot/SmiteSpam.alias.php";
$wgSpecialPages['SmiteSpam'] = 'SpecialSmiteSpam';
+$wgSpecialPages['SmiteSpamTrustedUsers'] = 'SpecialSmiteSpamTrustedUsers';
$wgAvailableRights[] = 'smitespam';
$wgGroupPermissions['sysop']['smitespam'] = true;
$wgAPIModules['smitespamanalyze'] = 'SmiteSpamApiQuery';
+$wgAPIModules['smitespamtrustuser'] = 'SmiteSpamApiTrustUser';
+
+$wgHooks['LoadExtensionSchemaUpdates'][] = 'SmiteSpamHooks::createTables';
$wgResourceModules['ext.SmiteSpam.retriever'] = array(
'scripts' => 'js/ext.smitespam.js',
diff --git a/SpecialSmiteSpamTrustedUsers.php b/SpecialSmiteSpamTrustedUsers.php
new file mode 100644
index 0000000..7f7d5b7
--- /dev/null
+++ b/SpecialSmiteSpamTrustedUsers.php
@@ -0,0 +1,132 @@
+<?php
+
+class SpecialSmiteSpamTrustedUsers extends SpecialPage {
+
+ public function __construct() {
+ parent::__construct( 'SmiteSpamTrustedUsers', 'smitespam' );
+ }
+
+ public function execute( $subPage ) {
+ if ( !$this->userCanExecute( $this->getUser() ) ) {
+ $this->displayRestrictionError();
+ return;
+ }
+ $this->setHeaders();
+ $out = $this->getOutput();
+ $request = $this->getRequest();
+
+ if ( $request->wasPosted() ) {
+ if ( $request->getVal( 'add' ) ) {
+ $username = $request->getText( 'username' );
+ $user = User::newFromName( $username );
+ if ( $user && $user->getId() !== 0 ) {
+ $dbr = wfGetDB( DB_SLAVE );
+ $result = $dbr->selectRow(
+ array( 'smitespam_trusted_user'
),
+ 'trusted_user_id',
+ array( 'trusted_user_id = ' .
$user->getId() )
+ );
+
+ if ( $result ) {
+ // TODO i18n
+ $out->addHTML(
+ '<div
class="errorbox">' .
+ "<p>User '$username'
already trusted.</p>" .
+ '</div>'
+ );
+ } else {
+ $dbw = wfGetDB( DB_MASTER );
+
+ $dbw->insert(
+
'smitespam_trusted_user',
+ array(
+
'trusted_user_id' => $user->getId(),
+
'trusted_user_timestamp' => $dbw->timestamp(),
+
'trusted_user_admin_id' => $this->getUser()->getID()
+ )
+ );
+ // TODO i18n
+ $out->addHTML(
+ '<div
class="successbox">' .
+ "<p>Trusted user
'$username'.</p>" .
+ '</div>'
+ );
+ }
+ } else {
+ // TODO i18n
+ $out->addHTML(
+ '<div class="errorbox">' .
+ "<p>User '$username' does not
exist.</p>" .
+ '</div>'
+ );
+ }
+ } else {
+ $usernameToDelete = $request->getText( 'remove'
);
+ if ( $usernameToDelete ) {
+ $user = User::newFromName(
$usernameToDelete );
+ if ( $user && $user->getId() !== 0 ) {
+ $dbw = wfGetDB( DB_MASTER );
+ $dbw->delete(
+
'smitespam_trusted_user',
+ array( 'trusted_user_id
= ' . $user->getId() )
+ );
+ // TODO i18n
+ $out->addHTML(
+ '<div
class="successbox">' .
+ "<p>Removed user
'$usernameToDelete' from trusted users.</p>" .
+ '</div>'
+ );
+ }
+ }
+ }
+ }
+
+ $dbr = wfGetDB( DB_SLAVE );
+ $result = $dbr->select(
+ array( 'smitespam_trusted_user' ),
+ array( 'trusted_user_id', 'trusted_user_timestamp',
'trusted_user_admin_id' ),
+ array(),
+ __METHOD__,
+ array(
+ "ORDER BY" => "trusted_user_timestamp ASC",
+ )
+ );
+
+ $out->addHTML( "<form method=\"post\">" );
+
+ $out->addHTML( '<label>Add user: <input type="text"
name="username"></label>' .
+ ' <input type="submit" value="Add" name="add">' );
+
+ // TODO i18n
+ $out->addHTML( '<table class="wikitable"><tr>' .
+ '<th>Trusted User</th>' .
+ '<th>Timestamp</th>' .
+ '<th>Trusting Admin</th>' .
+ '<th>Remove</th>' .
+ '</tr>'
+ );
+ foreach ( $result as $row ) {
+ $trustedUser = User::newFromID( $row->trusted_user_id
)->getName();
+ $trustedUserContribsLink = Linker::link(
+ SpecialPage::getTitleFor( 'Contributions',
$trustedUser ),
+ Sanitizer::escapeHtmlAllowEntities(
$trustedUser ),
+ array( 'target' => '_blank' )
+ );
+ $timestamp = wfTimestamp( TS_RFC2822,
$row->trusted_user_timestamp );
+ $admin = User::newFromID( $row->trusted_user_admin_id
)->getName();
+ $adminContribsLink = Linker::link(
+ SpecialPage::getTitleFor( 'Contributions',
$admin ),
+ Sanitizer::escapeHtmlAllowEntities( $admin ),
+ array( 'target' => '_blank' )
+ );
+ $out->addHTML(
+ "<tr><td>$trustedUserContribsLink</td>" .
+ "<td>$timestamp</td>" .
+ "<td>$adminContribsLink</td>" .
+ "<td><button type=\"submit\" name=\"remove\"
value=\"$trustedUser\">Remove</button></tr>"
+ );
+ }
+ $out->addHTML( '</table>' );
+ $out->addHTML( "</form>" );
+ }
+}
diff --git a/api/SmiteSpamApiTrustUser.php b/api/SmiteSpamApiTrustUser.php
new file mode 100644
index 0000000..9744286
--- /dev/null
+++ b/api/SmiteSpamApiTrustUser.php
@@ -0,0 +1,77 @@
+<?php
+
+class SmiteSpamApiTrustUser extends ApiBase {
+ public function execute() {
+
+ if ( !in_array( 'smitespam', $this->getUser()->getRights() ) ) {
+ $this->dieUsage( 'Permission error.',
'permissiondenied' );
+ }
+ $username = $this->getMain()->getVal( 'username' );
+
+ $user = User::newFromName( $username );
+ if ( !$user || $user->getId() === 0 ) {
+ $this->dieUsage( 'Not a valid username.', 'badparams' );
+ }
+
+ $dbr = wfGetDB( DB_SLAVE );
+
+ $result = $dbr->selectRow(
+ array( 'smitespam_trusted_user' ),
+ 'trusted_user_id',
+ array( 'trusted_user_id = ' . $user->getId() )
+ );
+
+ if ( $result ) {
+ $this->dieUsage( 'User already trusted.', 'duplicate' );
+ }
+
+ $dbw = wfGetDB( DB_MASTER );
+
+ $dbw->insert(
+ 'smitespam_trusted_user',
+ array(
+ 'trusted_user_id' => $user->getId(),
+ 'trusted_user_timestamp' => $dbw->timestamp(),
+ 'trusted_user_admin_id' =>
$this->getUser()->getID()
+ )
+ );
+
+ $result = $this->getResult();
+ $result->addValue(
+ null,
+ $this->getModuleName(),
+ array ( 'success' => 1 )
+ );
+ return true;
+ }
+
+ // Description
+ public function getDescription() {
+ return 'Trust a user so that SmiteSpam ignores pages created by
the user.';
+ }
+
+ // Face parameter.
+ public function getAllowedParams() {
+ return array_merge( parent::getAllowedParams(), array(
+ 'username' => array(
+ ApiBase::PARAM_TYPE => 'string',
+ ApiBase::PARAM_REQUIRED => true
+ )
+ ) );
+ }
+
+ // Describe the parameter
+ public function getParamDescription() {
+ return array_merge( parent::getParamDescription(), array(
+ 'username' => 'Username of user to trust.',
+ ) );
+ }
+
+ // Get examples
+ public function getExamples() {
+ return array(
+ 'api.php?action=smitespamtrustuser&username=Admin'
+ => 'Trust user "Admin"'
+ );
+ }
+}
diff --git a/autoload.php b/autoload.php
index 8525ea8..395f6a3 100644
--- a/autoload.php
+++ b/autoload.php
@@ -6,10 +6,13 @@
$wgAutoloadClasses += array(
'SmiteSpamAnalyzer' => __DIR__ . '/includes/SmiteSpamAnalyzer.php',
'SmiteSpamApiQuery' => __DIR__ . '/api/SmiteSpamApiQuery.php',
+ 'SmiteSpamApiTrustUser' => __DIR__ . '/api/SmiteSpamApiTrustUser.php',
'SmiteSpamDeleter' => __DIR__ . '/includes/SmiteSpamDeleter.php',
'SmiteSpamExternalLinksChecker' => __DIR__ .
'/includes/checkers/SmiteSpamExternalLinksChecker.php',
+ 'SmiteSpamHooks' => __DIR__ . '/SmiteSpam.hooks.php',
'SmiteSpamRepeatedExternalLinksChecker' => __DIR__ .
'/includes/checkers/SmiteSpamRepeatedExternalLinksChecker.php',
'SmiteSpamWikiPage' => __DIR__ . '/includes/SmiteSpamWikiPage.php',
'SmiteSpamWikitextChecker' => __DIR__ .
'/includes/checkers/SmiteSpamWikitextChecker.php',
'SpecialSmiteSpam' => __DIR__ . '/SpecialSmiteSpam.php',
+ 'SpecialSmiteSpamTrustedUsers' => __DIR__ .
'/SpecialSmiteSpamTrustedUsers.php',
);
diff --git a/generate-autoloads.php b/generate-autoloads.php
index c2b34de..829b250 100644
--- a/generate-autoloads.php
+++ b/generate-autoloads.php
@@ -5,6 +5,8 @@
$gen = new AutoloadGenerator( __DIR__ );
$gen->readFile( __DIR__ . '/SpecialSmiteSpam.php' );
+$gen->readFile( __DIR__ . '/SpecialSmiteSpamTrustedUsers.php' );
+$gen->readFile( __DIR__ . '/SmiteSpam.hooks.php' );
$gen->readDir( __DIR__ . '/includes' );
$gen->readDir( __DIR__ . '/api' );
diff --git a/i18n/en.json b/i18n/en.json
index 41cac73..1bd716a 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -21,5 +21,6 @@
"smitespam-probability-medium": "Medium",
"smitespam-probability-high": "High",
"smitespam-probability-very-high": "Very high",
- "smitespam-select": "Select: "
+ "smitespam-select": "Select: ",
+ "smitespamtrustedusers": "SmiteSpam Trusted Users"
}
diff --git a/i18n/qqq.json b/i18n/qqq.json
index b17e47f..ed7866e 100644
--- a/i18n/qqq.json
+++ b/i18n/qqq.json
@@ -23,5 +23,6 @@
"smitespam-probability-medium": "Medium
probability\n{{Identical|Medium}}",
"smitespam-probability-high": "High probability\n{{Identical|High}}",
"smitespam-probability-very-high": "Very high probability",
- "smitespam-select": "Followed by two links:
{{msg-mw|Powersearch-toggleall}} and {{msg-mw|Powersearch-togglenone}} which
respectively selects all pages and de-selects all pages\n{{Identical|Select}}"
+ "smitespam-select": "Followed by two links:
{{msg-mw|Powersearch-toggleall}} and {{msg-mw|Powersearch-togglenone}} which
respectively selects all pages and de-selects all pages\n{{Identical|Select}}",
+ "smitespamtrustedusers": "Title of the Special:SmiteSpamTrustedUsers
page"
}
diff --git a/includes/SmiteSpamAnalyzer.php b/includes/SmiteSpamAnalyzer.php
index 908c9ed..7dd45c4 100644
--- a/includes/SmiteSpamAnalyzer.php
+++ b/includes/SmiteSpamAnalyzer.php
@@ -32,6 +32,17 @@
public function run( $offset = 0, $limit = 500 ) {
$dbr = wfGetDB( DB_SLAVE );
+ $usersResult = $dbr->select(
+ array( 'smitespam_trusted_user' ),
+ 'trusted_user_id'
+ );
+
+ $trustedUsers = array();
+
+ foreach ( $usersResult as $row ) {
+ $trustedUsers[] = $row->trusted_user_id;
+ }
+
$result = $dbr->select(
array( 'page' ),
'page_id',
@@ -65,6 +76,12 @@
continue;
}
+ $creatorID = $page->getOldestRevision()->getUser(
Revision::RAW );
+
+ if ( in_array( $creatorID, $trustedUsers ) ) {
+ continue;
+ }
+
if ( $this->config['ignorePagesWithNoExternalLinks']
&& count( $page->getMetadata( 'externalLinks' ) )
== 0 ) {
continue;
diff --git a/includes/SmiteSpamWikiPage.php b/includes/SmiteSpamWikiPage.php
index 1d2014a..297a2cb 100644
--- a/includes/SmiteSpamWikiPage.php
+++ b/includes/SmiteSpamWikiPage.php
@@ -10,6 +10,12 @@
private $metadata;
/**
+ * The Revision object of the oldest revision
+ * @var Revision|null
+ */
+ private $oldestRevision;
+
+ /**
* A probability-like value representing how likely this page is a spam
page.
* @var float
*/
@@ -27,6 +33,13 @@
$this->metadata = array();
}
+ public function getOldestRevision() {
+ if ( !$this->oldestRevision ) {
+ $this->oldestRevision = parent::getOldestRevision();
+ }
+ return $this->oldestRevision;
+ }
+
/**
* Return particular field of metadata
* @param string $key
diff --git a/smitespam.sql b/smitespam.sql
new file mode 100644
index 0000000..0d82bc3
--- /dev/null
+++ b/smitespam.sql
@@ -0,0 +1,15 @@
+--
+-- List of trusted users for SmiteSpam to ignore
+--
+CREATE TABLE /*_*/smitespam_trusted_user (
+ -- User ids of trusted users
+ trusted_user_id int unsigned NOT NULL,
+
+ -- Timestamp of when a user was marked as trusted
+ trusted_user_timestamp binary(14) NOT NULL default '',
+
+ -- User ID of admin who marked a user as trusted
+ trusted_user_admin_id int unsigned NOT NULL
+) /*$wgDBTableOptions*/;
+
+CREATE UNIQUE INDEX /*i*/trusted_user_id ON /*_*/smitespam_trusted_user
(trusted_user_id);
diff --git a/static/js/ext.smitespam.js b/static/js/ext.smitespam.js
index d21b0fc..34a0f81 100644
--- a/static/js/ext.smitespam.js
+++ b/static/js/ext.smitespam.js
@@ -41,10 +41,17 @@
ajaxQueries.pages.numSent++;
},
processResponse: function ( data ) {
- var receivedPages = data.smitespamanalyze.pages;
- $.extend( users, data.smitespamanalyze.users );
- $.merge( results, receivedPages );
- displayResults();
+ if ( 'smitespamanalyze' in data ) {
+ var receivedPages = data.smitespamanalyze.pages;
+ $.extend( users, data.smitespamanalyze.users );
+ $.merge( results, receivedPages );
+ displayResults();
+ } else if ( 'error' in data ) {
+ if ( data.error.code ===
'internal_api_error_DBQueryError' ) {
+ createErrorbox();
+ $( '<p>' ).text( 'Database error! Did
you forget to run maintenance/update.php?' ).appendTo( '#ajax-errorbox' );
+ }
+ }
}
};
@@ -78,12 +85,7 @@
row.remove();
}
refreshRangeDisplayer();
- if ( $( '#ajax-successbox' ).length === 0 ) {
- var $successbox = $( '<div>', { id:
'ajax-successbox' } )
- .addClass( 'successbox' );
- $( '#pagination' ).append( $successbox
);
- $( '#pagination' ).append( '<br>' );
- }
+ createSuccessbox();
// TODO i18n
$( '#ajax-successbox' ).append( '<p>Page "' +
pageTitleText + '" deleted.</p>' );
} else if ( 'error' in data ) {
@@ -91,12 +93,7 @@
if ( row.length ) {
row.find( 'td' ).eq( 3 ).text( mw.msg(
'smitespam-delete-page-failure-msg' ) );
}
- if ( $( '#ajax-errorbox' ).length === 0 ) {
- var $errorbox = $( '<div>', { id:
'ajax-errorbox' } )
- .addClass( 'errorbox' );
- $( '#pagination' ).append( $errorbox );
- $( '#pagination' ).append( '<br>' );
- }
+ createErrorbox();
// TODO i18n
$( '#ajax-errorbox' ).append( '<p>Failed to
delete page "' + pageTitleText + '".</p>' );
}
@@ -133,18 +130,14 @@
$( '#smitespam-page-list th
.block-checkbox-container' ).each( function () {
var $this = $( this );
if ( $this.parent().data( 'username' )
=== username ) {
+ $this.empty();
// TODO i18n
- $this.parent().append( '
(Blocked)' );
- $this.remove();
+ $this.append( ' ·
(Blocked)' );
+ $this.parent().find(
'.trust-user-button-container' ).remove();
return false;
}
} );
- if ( $( '#ajax-successbox' ).length === 0 ) {
- var $successbox = $( '<div>', { id:
'ajax-successbox' } )
- .addClass( 'successbox' );
- $( '#pagination' ).append( $successbox
);
- $( '#pagination' ).append( '<br>' );
- }
+ createSuccessbox();
// TODO i18n
$( '#ajax-successbox' ).append( '<p>User "' +
username + '" blocked.</p>' );
} else if ( 'error' in data ) {
@@ -152,18 +145,13 @@
$( '#smitespam-page-list
.block-checkbox-container' ).each( function () {
var $this = $( this );
if ( $this.parent().data( 'username' )
=== username ) {
+ $this.empty();
// TODO i18n
- $this.parent().append( '
(Failed to block)' );
- $this.remove();
+ $this.append( ' ·
(Failed to block)' );
return false;
}
} );
- if ( $( '#ajax-errorbox' ).length === 0 ) {
- var $errorbox = $( '<div>', { id:
'ajax-errorbox' } )
- .addClass( 'errorbox' );
- $( '#pagination' ).append( $errorbox );
- $( '#pagination' ).append( '<br>' );
- }
+ createErrorbox();
// TODO i18n
$( '#ajax-errorbox' ).append( '<p>Failed to
block user "' + username + '".</p>' );
}
@@ -248,6 +236,34 @@
}
}
+ function onTrustUserButtonClick() {
+ var $this = $( this );
+ var username = $this
+ .parent() // button container
+ .parent() // creator cell
+ .data( 'username' );
+
+ $.getJSON( mw.config.get( 'wgScriptPath' ) +
'/api.php?action=smitespamtrustuser&format=json&username=' + username,
+ function ( data ) {
+ if ( 'smitespamtrustuser' in data ) {
+ $this.parent().parent().find(
'.block-checkbox-container' ).remove();
+ // TODO i18n
+ $this.parent().append(
'Trusted' );
+ $this.remove();
+ createSuccessbox();
+
+ $( '#ajax-successbox' ).append(
'<p>Trusted user "' + username + '".</p>' );
+ } else {
+ // TODO i18n
+ $this.parent().append( 'Failed
to trust' );
+ $this.remove();
+ createErrorbox();
+ $( '#ajax-errorbox' ).append(
'<p>Failed to trust user "' + username + '".</p>' );
+ }
+ }
+ );
+ }
+
$( '#smitespam-page-list' ).empty();
for ( i = 0; i < groupedPages.length; i++ ) {
var group = groupedPages[i].pages;
@@ -273,11 +289,20 @@
if ( $.inArray( $blockCheckbox.val(),
usersToBlock ) !== -1 ) {
$blockCheckbox.attr( 'checked',
'checked' );
}
+ $blockCheckboxContainer.append( '
· ' );
$blockCheckboxContainer.append(
$blockCheckbox );
// TODO i18n
$blockCheckboxContainer.append( 'Block'
);
- $creatorCell.append( ' · ' );
$creatorCell.append(
$blockCheckboxContainer );
+
+ var $trustUserButtonContainer = $(
'<span>' ).addClass( 'trust-user-button-container' );
+ $trustUserButtonContainer.append( '
· ' );
+ // TODO i18n
+ var $trustUserButton = $( '<button>'
).text( 'Trust' )
+ .on( 'click',
onTrustUserButtonClick );
+
+ $trustUserButtonContainer.append(
$trustUserButton );
+ $creatorCell.append(
$trustUserButtonContainer );
}
}
var $creatorRow = $( '<tr>' ).append( $creatorCell );
@@ -363,6 +388,24 @@
$( '#smitespam-displayed-range' ).show();
}
+ function createSuccessbox() {
+ if ( $( '#ajax-successbox' ).length === 0 ) {
+ var $successbox = $( '<div>', { id: 'ajax-successbox' }
)
+ .addClass( 'successbox' );
+ $( '#pagination' ).append( $successbox );
+ $( '#pagination' ).append( '<br>' );
+ }
+ }
+
+ function createErrorbox() {
+ if ( $( '#ajax-errorbox' ).length === 0 ) {
+ var $errorbox = $( '<div>', { id: 'ajax-errorbox' } )
+ .addClass( 'errorbox' );
+ $( '#pagination' ).append( $errorbox );
+ $( '#pagination' ).append( '<br>' );
+ }
+ }
+
function init() {
var $pagination = $( '#pagination' );
// TODO i18n
--
To view, visit https://gerrit.wikimedia.org/r/225928
To unsubscribe, visit https://gerrit.wikimedia.org/r/settings
Gerrit-MessageType: merged
Gerrit-Change-Id: I4e2e14677f5b02613c2632a2ec9209bd750c8e7a
Gerrit-PatchSet: 3
Gerrit-Project: mediawiki/extensions/SmiteSpam
Gerrit-Branch: master
Gerrit-Owner: Polybuildr <[email protected]>
Gerrit-Reviewer: Polybuildr <[email protected]>
Gerrit-Reviewer: Springle <[email protected]>
Gerrit-Reviewer: Yaron Koren <[email protected]>
Gerrit-Reviewer: jan <[email protected]>
Gerrit-Reviewer: jenkins-bot <>
_______________________________________________
MediaWiki-commits mailing list
[email protected]
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits