jenkins-bot has submitted this change and it was merged.

Change subject: Add support for self-service password resets
......................................................................


Add support for self-service password resets

Add a screen to request a password reset token to be delivered by email
and an associated screen to validate the reset token and allow
a password reset. The recovery tokens issued will automatically expire
on the next password change or in 48 hours if not used.

Bug: T116110
Change-Id: I0eb3aa722954c8cc9bc395f427aee1beaa80c2e1
---
A data/db/migrations/20151020-add-user-reset-token.sql
A data/db/migrations/20151021-add-user-reset-date.sql
M data/db/schema.mysql
M data/i18n/en.json
M data/i18n/qqq.json
A data/templates/account/recover.html
A data/templates/account/reset.html
M data/templates/login.html
M data/templates/nav_login.html
M src/App.php
A src/Controllers/Account/Recover.php
A src/Controllers/Account/Reset.php
M src/Dao/Users.php
M src/Password.php
M tests/PasswordTest.php
15 files changed, 462 insertions(+), 3 deletions(-)

Approvals:
  CSteipp: Looks good to me, approved
  Siebrand: Looks good to me, but someone else must approve
  jenkins-bot: Verified



diff --git a/data/db/migrations/20151020-add-user-reset-token.sql 
b/data/db/migrations/20151020-add-user-reset-token.sql
new file mode 100644
index 0000000..1080e94
--- /dev/null
+++ b/data/db/migrations/20151020-add-user-reset-token.sql
@@ -0,0 +1,4 @@
+ALTER TABLE users
+  ADD COLUMN reset_hash VARCHAR(64) DEFAULT NULL
+  AFTER blocked;
+
diff --git a/data/db/migrations/20151021-add-user-reset-date.sql 
b/data/db/migrations/20151021-add-user-reset-date.sql
new file mode 100644
index 0000000..1e96ab8
--- /dev/null
+++ b/data/db/migrations/20151021-add-user-reset-date.sql
@@ -0,0 +1,3 @@
+ALTER TABLE users
+  ADD COLUMN reset_date TIMESTAMP NULL DEFAULT NULL
+  AFTER reset_hash;
diff --git a/data/db/schema.mysql b/data/db/schema.mysql
index 8d34383..6f64230 100644
--- a/data/db/schema.mysql
+++ b/data/db/schema.mysql
@@ -10,6 +10,8 @@
   , isadmin     TINYINT(1) DEFAULT NULL
   , viewreports TINYINT(1) DEFAULT NULL
   , blocked     TINYINT(1) DEFAULT 0
+  , reset_hash  VARCHAR(64) DEFAULT NULL
+  , reset_date  TIMESTAMP NULL DEFAULT NULL
   , PRIMARY KEY (id)
   , UNIQUE KEY username (username)
 ) ENGINE=InnoDB DEFAULT CHARACTER SET=utf8 AUTO_INCREMENT=1000 ;
diff --git a/data/i18n/en.json b/data/i18n/en.json
index da16580..fd7d5db 100644
--- a/data/i18n/en.json
+++ b/data/i18n/en.json
@@ -46,6 +46,20 @@
        "login-failed": "Login failed.",
        "login-error": "Username and password required.",
 
+       "forgot-password": "Forgot your password?",
+       "forgot-password-email": "Email address:",
+       "forgot-password-submit": "Send recovery email",
+       "recover-account-subject": "Grants review password recovery",
+       "recover-account-email": "A request was made at $3 to reset the 
password for your user account.\n\nRecovery information:\n\nusername: 
$1\nrecovery URL: $2\n\nThe recovery URL will expire in 48 hours. If you did 
not make this request you can ignore this email. Your account has not been 
compromised.\n\nSincerely,\nGrants review administrators",
+       "recover-account-success": "Recovery instructions sent. Check your 
email and spam folder.",
+       "recover-account-bad-input": "Invalid input.",
+       "reset-password-bad-token": "Invalid or expired recovery token 
supplied.",
+       "reset-password-no-match": "Passwords do not match.",
+       "reset-password-empty": "Password can not be empty.",
+       "reset-password-success": "Password reset successful.",
+       "reset-password-fail": "Password reset failed.",
+       "reset-password-invalid": "Invalid input.",
+
        "error-heading": "System error",
        "error-message": "An unexpected error has occurred and we are working 
to fix the problem. Please try again in a moment.",
        "error-id": "Error ID: $1",
diff --git a/data/i18n/qqq.json b/data/i18n/qqq.json
index 272ce4c..35b20c5 100644
--- a/data/i18n/qqq.json
+++ b/data/i18n/qqq.json
@@ -84,6 +84,9 @@
        "error-message": "Fatal error page content",
        "footer-credits": "Page footer navigation label, links to credits 
page.\n{{Identical|Credit}}",
        "footer-privacy": "Page footer navigation label, links to privacy 
page.\n{{Identical|Privacy}}",
+       "forgot-password": "Link text, links to account recovery page.",
+       "forgot-password-email": "Label for form field",
+       "forgot-password-submit": "Label for form submit button",
        "form-conditional": "Radio button label.",
        "form-neutral": "Radio button label.",
        "form-no": "Radio button label.",
@@ -177,6 +180,16 @@
        "report-aggregated-theme": "Report column header",
        "report-format-recommend": "Report column value.\n\n* $1 - Number of 
reviewers recommending proposal\n* $2 - \"*\" if some recommendations were 
conditional\n* $3 - Total number of reviews\n* $4 - Percentage of reviews 
recomending approval.",
        "proposals-search-campaigns-empty": "Select list usage prompt.",
+       "recover-account-subject": "Email subject",
+       "recover-account-email": "Email body. Parameters:\n* $1 - Account 
username\n* $2 - URL to password recovery page of application\n* $3 - URL to 
main page of application",
+       "recover-account-success": "Success message for account recovery 
request",
+       "recover-account-bad-input": "Error message for account recovery 
request",
+       "reset-password-bad-token": "Error message for password reset request",
+       "reset-password-empty": "Error message for password reset request",
+       "reset-password-fail": "Error message for password reset request",
+       "reset-password-invalid": "Error message for password reset request",
+       "reset-password-no-match": "Error message for password reset request",
+       "reset-password-success": "Success message for password reset request",
        "reports-wikitext-go": "Form submit button label.",
        "review-instructions": "Wikitext formatted form instructions.",
        "review-rank-1": "Radio button label.",
diff --git a/data/templates/account/recover.html 
b/data/templates/account/recover.html
new file mode 100644
index 0000000..f93a2ca
--- /dev/null
+++ b/data/templates/account/recover.html
@@ -0,0 +1,25 @@
+{% extends "base.html" %}
+
+{% block subtitle %}{{ 'forgot-password'|message }}{% endblock %}
+{% block content %}
+{% spaceless %}
+<ol class="breadcrumb">
+  <li>{{ 'forgot-password'|message }}</li>
+</ol>
+
+<form class="form-horizontal" method="post" action="{{ urlFor( 
'account_recover_post' ) }}">
+  <input type="hidden" name="{{ csrf_param }}" value="{{ csrf_token }}" />
+
+  <div class="form-group">
+    <label class="col-sm-2 control-label" for="email">{{ 
'forgot-password-email'|message }}</label>
+    <div class="col-sm-10">
+      <input type="email" class="form-control" name="email" id="email" 
value="" required="required"/>
+    </div>
+  </div>
+
+  <div class="col-sm-10 col-sm-offset-2">
+    <input type="submit" class="btn btn-default" id="save" name="save" 
value="{{ 'forgot-password-submit'|message }}"/>
+  </div>
+</form>
+{% endspaceless %}
+{% endblock content %}
diff --git a/data/templates/account/reset.html 
b/data/templates/account/reset.html
new file mode 100644
index 0000000..259ccbe
--- /dev/null
+++ b/data/templates/account/reset.html
@@ -0,0 +1,33 @@
+{% extends "base.html" %}
+
+{% block subtitle %}{{ 'change-password'|message }}{% endblock %}
+{% block navbar %}{% endblock %}
+{% block content %}
+{% spaceless %}
+<ol class="breadcrumb">
+  <li>{{ 'change-password'|message }}</li>
+</ol>
+
+<form class="form-horizontal" method="post" action="{{ urlFor( 
'account_reset_post', { 'uid': id, 'token': token } ) }}">
+  <input type="hidden" name="{{ csrf_param }}" value="{{ csrf_token }}" />
+
+  <div class="form-group">
+    <label class="col-sm-2 control-label" for="newpw1">{{ 
'new-password'|message }}</label>
+    <div class="col-sm-10">
+      <input type="password" class="form-control" name="newpw1" id="newpw1" 
value="" required="required"/>
+    </div>
+  </div>
+
+  <div class="form-group">
+    <label class="col-sm-2 control-label" for="newpw2">{{ 
'new-password-again'|message }}</label>
+    <div class="col-sm-10">
+      <input type="password" class="form-control" name="newpw2" id="newpw2" 
value="" required="required"/>
+    </div>
+  </div>
+
+  <div class="col-sm-10 col-sm-offset-2">
+    <input type="submit" class="btn btn-default" id="save" name="save" 
value="{{ 'save-password'|message }}"/>
+  </div>
+</form>
+{% endspaceless %}
+{% endblock content %}
diff --git a/data/templates/login.html b/data/templates/login.html
index 5b57270..37de577 100644
--- a/data/templates/login.html
+++ b/data/templates/login.html
@@ -31,6 +31,7 @@
         </div>
       </div>
     </form>
+    <p><a href="{{ urlFor( 'account_recover' ) }}">{{ 
'forgot-password'|message }}</a></p>
   </div>
 </div>
 {% endspaceless %}
diff --git a/data/templates/nav_login.html b/data/templates/nav_login.html
index d3a3579..6348ef9 100644
--- a/data/templates/nav_login.html
+++ b/data/templates/nav_login.html
@@ -10,5 +10,8 @@
     <input type="password" class="form-control" id="password" name="password" 
required="required" placeholder="{{ 'password'|message }}"/>
   </div>
   <input type="submit" class="btn btn-default" value="{{ 'login'|message }}" />
+  <div class="form-group">
+    <a href="{{ urlFor( 'account_recover' ) }}">{{ 'forgot-password'|message 
}}</a>
+  </div>
 </form>
 {% endblock login_form %}
diff --git a/src/App.php b/src/App.php
index bfe0b29..d4b114c 100644
--- a/src/App.php
+++ b/src/App.php
@@ -439,6 +439,45 @@
                        }
                );
 
+               // Account management helpers
+               $slim->group( '/account/',
+                       $middleware['inject-user'],
+                       function () use ( $slim, $middleware ) {
+                               $slim->get( 'recover', 
$middleware['must-revalidate'],
+                                       function () use ( $slim ) {
+                                               $page = new 
Controllers\Account\Recover( $slim );
+                                               $page->setDao( $slim->usersDao 
);
+                                               $page();
+                                       }
+                               )->name( 'account_recover' );
+
+                               $slim->post( 'recover.post', 
$middleware['must-revalidate'],
+                                       function () use ( $slim ) {
+                                               $page = new 
Controllers\Account\Recover( $slim );
+                                               $page->setDao( $slim->usersDao 
);
+                                               $page->setMailer( $slim->mailer 
);
+                                               $page();
+                                       }
+                               )->name( 'account_recover_post' );
+
+                               $slim->get( 'reset/:token/:uid', 
$middleware['must-revalidate'],
+                                       function ( $token, $uid ) use ( $slim ) 
{
+                                               $page = new 
Controllers\Account\Reset( $slim );
+                                               $page->setDao( $slim->usersDao 
);
+                                               $page( $uid, $token );
+                                       }
+                               )->name( 'account_reset' );
+
+                               $slim->post( 'reset.post/:token/:uid', 
$middleware['must-revalidate'],
+                                       function ( $token, $uid ) use ( $slim ) 
{
+                                               $page = new 
Controllers\Account\Reset( $slim );
+                                               $page->setDao( $slim->usersDao 
);
+                                               $page( $uid, $token );
+                                       }
+                               )->name( 'account_reset_post' );
+                       }
+               );
+
                // Routes for authenticated users
                $slim->group( '/user/',
                        $middleware['must-revalidate'],
diff --git a/src/Controllers/Account/Recover.php 
b/src/Controllers/Account/Recover.php
new file mode 100644
index 0000000..481f446
--- /dev/null
+++ b/src/Controllers/Account/Recover.php
@@ -0,0 +1,92 @@
+<?php
+/**
+ * @section LICENSE
+ * This file is part of Wikimedia Grants Review application.
+ *
+ * Wikimedia Grants Review application is free software: you can
+ * redistribute it and/or modify it under the terms of the GNU General Public
+ * License as published by the Free Software Foundation, either version 3 of
+ * the License, or (at your option) any later version.
+ *
+ * Wikimedia Grants Review application is distributed in the hope that it
+ * will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty
+ * of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with Wikimedia Grants Review application.  If not, see
+ * <http://www.gnu.org/licenses/>.
+ *
+ * @file
+ * @copyright © 2014 Bryan Davis, Wikimedia Foundation and contributors.
+ */
+
+namespace Wikimedia\IEGReview\Controllers\Account;
+
+use Wikimedia\IEGReview\Controller;
+
+/**
+ * Request an account recovery by providing an email address
+ *
+ * @author Bryan Davis <[email protected]>
+ * @copyright © 2014 Bryan Davis, Wikimedia Foundation and contributors.
+ */
+class Recover extends Controller {
+
+       protected function handleGet() {
+               $this->render( 'account/recover.html' );
+       }
+
+
+       protected function handlePost() {
+               $this->form->requireEmail( 'email' );
+
+               $dest = $this->urlFor( 'login' );
+               if ( $this->form->validate() ) {
+                       $email = $this->form->get( 'email' );
+                       $data  = $this->dao->createPasswordResetToken( $email );
+                       foreach ( $data as $result ) {
+                               list( $token, $user ) = $result;
+                               if ( $token !== false ) {
+                                       $this->sendEmail( $user, $token );
+                               }
+                       }
+                       $this->flash( 'info',
+                               $this->i18nContext->message( 
'recover-account-success' )
+                       );
+               } else {
+                       $this->flash( 'error',
+                               $this->i18nContext->message( 
'recover-account-bad-input' )
+                       );
+                       $dest =  $this->urlFor( 'account_recover' );
+               }
+
+               $this->redirect( $dest );
+       }
+
+       protected function sendEmail( $user, $token ) {
+               $sent = $this->mailer->mail(
+                       $user['email'],
+                       $this->i18nContext->message( 'recover-account-subject' 
),
+                       $this->i18nContext->message( 'recover-account-email', 
array(
+                               $user['username'],
+                               $this->request->getUrl() .
+                               $this->urlFor( 'account_reset', array(
+                                       'uid' => $user['id'],
+                                       'token' => $token,
+                               ) ),
+                               $this->request->getUrl() . $this->urlFor( 
'home' ),
+                       ) )
+               );
+
+               if ( !$sent ) {
+                       $this->logger->error(
+                               'Failed to send reset email for user',
+                               array(
+                                       'method' => __METHOD__,
+                                       'user' => $user['id'],
+                       ) );
+               }
+       }
+
+}
diff --git a/src/Controllers/Account/Reset.php 
b/src/Controllers/Account/Reset.php
new file mode 100644
index 0000000..8168350
--- /dev/null
+++ b/src/Controllers/Account/Reset.php
@@ -0,0 +1,88 @@
+<?php
+/**
+ * @section LICENSE
+ * This file is part of Wikimedia Grants Review application.
+ *
+ * Wikimedia Grants Review application is free software: you can
+ * redistribute it and/or modify it under the terms of the GNU General Public
+ * License as published by the Free Software Foundation, either version 3 of
+ * the License, or (at your option) any later version.
+ *
+ * Wikimedia Grants Review application is distributed in the hope that it
+ * will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty
+ * of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with Wikimedia Grants Review application.  If not, see
+ * <http://www.gnu.org/licenses/>.
+ *
+ * @file
+ * @copyright © 2014 Bryan Davis, Wikimedia Foundation and contributors.
+ */
+
+namespace Wikimedia\IEGReview\Controllers\Account;
+
+use Wikimedia\IEGReview\Controller;
+
+/**
+ * Reset password using recovery token
+ *
+ * @author Bryan Davis <[email protected]>
+ * @copyright © 2014 Bryan Davis, Wikimedia Foundation and contributors.
+ */
+class Reset extends Controller {
+
+       protected function handleGet( $id, $token ) {
+               if ( $this->dao->validatePasswordResetToken( $id, $token ) ) {
+                       $this->view->set( 'id', $id );
+                       $this->view->set( 'token', $token );
+                       $this->render( 'account/reset.html' );
+
+               } else {
+                       $this->flash( 'error',
+                               $this->i18nContext->message( 
'reset-password-bad-token' ) );
+                       $this->redirect( $this->urlFor( 'account_recover' ) );
+               }
+       }
+
+
+       protected function handlePost( $id, $token ) {
+               $this->form->requireString( 'newpw1' );
+               $this->form->requireString( 'newpw2' );
+
+               $dest = $this->urlFor( 'account_reset', array(
+                       'uid' => $id,
+                       'token' => $token,
+               ) );
+
+               if ( $this->form->validate() ) {
+                       $newPass = $this->form->get( 'newpw1' );
+
+                       if ( $newPass !== $this->form->get( 'newpw2' ) ) {
+                               $this->flash( 'error',
+                                       $this->i18nContext->message( 
'reset-password-no-match' ) );
+
+                       } elseif ( empty( $newPass ) ) {
+                               $this->flash( 'error',
+                                       $this->i18nContext->message( 
'reset-password-empty' ) );
+
+                       } else {
+                               if ( $this->dao->resetPassword( $id, $token, 
$newPass ) ) {
+                                       $this->flash( 'info',
+                                               $this->i18nContext->message( 
'reset-password-success' ) );
+                                       $dest = $this->urlFor( 'login' );
+                               } else {
+                                       $this->flash( 'error',
+                                               $this->i18nContext->message( 
'reset-password-fail' ) );
+                               }
+                       }
+               } else {
+                       $this->flash( 'error',
+                               $this->i18nContext->message( 
'reset-password-invalid' ) );
+               }
+
+               $this->redirect( $dest );
+       }
+
+}
diff --git a/src/Dao/Users.php b/src/Dao/Users.php
index 960c50d..52ab4cd 100644
--- a/src/Dao/Users.php
+++ b/src/Dao/Users.php
@@ -194,9 +194,11 @@
                        $stmt->execute( array( Password::encodePassword( $newpw 
), $id ) );
                        $this->dbh->commit();
                        $this->logger->notice( 'Changed password for user', 
array(
-                                       'method' => __METHOD__,
-                                       'user' => $id,
+                               'method' => __METHOD__,
+                               'user' => $id,
                        ) );
+                       // Invalidate any password reset token that may have 
been issued
+                       $this->updatePasswordResetHash( $id, null );
                        return true;
 
                } catch ( PDOException $e) {
@@ -243,4 +245,92 @@
                return $this->update( $sql, $params );
        }
 
+       /**
+        * Generate password reset token(s) for the given email address.
+        *
+        * @param string $email Email address
+        * @return array (token, user) pairs; token === false on error
+        */
+       public function createPasswordResetToken( $email ) {
+               $ret = array();
+               $users = $this->search( array(
+                       'email' => $email,
+                       'items' => 'all',
+               ) );
+               foreach ( $users->rows as $user ) {
+                       $token = bin2hex( Password::getBytes( 16, true ) );
+                       $hash = hash( 'sha256', $token );
+                       if ( !$this->updatePasswordResetHash( $user['id'], 
$hash ) ) {
+                               $token = false;
+                       }
+                       $ret[] = array( $token, $user );
+               }
+               return $ret;
+       }
+
+       protected function updatePasswordResetHash( $id, $hash ) {
+               $ret = false;
+               $stmt = $this->dbh->prepare(
+                       'UPDATE users SET reset_hash = ?, reset_date = now() 
WHERE id = ?'
+               );
+               try {
+                       $this->dbh->beginTransaction();
+                       $stmt->execute( array( $hash, $id ) );
+                       $this->dbh->commit();
+                       $this->logger->notice( 'Created reset token for user', 
array(
+                               'method' => __METHOD__,
+                               'user' => $id,
+                       ) );
+                       $ret = true;
+
+               } catch ( PDOException $e) {
+                       $this->dbh->rollback();
+                       $this->logger->error(
+                               'Failed to update reset_hash for user',
+                               array(
+                                       'method' => __METHOD__,
+                                       'exception' => $e,
+                       ) );
+               }
+               return $ret;
+       }
+
+       /**
+        * Validate a user's password reset token.
+        *
+        * @param int $id User id
+        * @param string $token Reset token
+        * @return bool
+        */
+       public function validatePasswordResetToken( $id, $token ) {
+               $hash = hash( 'sha256', $token );
+               $row = $this->fetch(
+                       'SELECT reset_hash, reset_date FROM users WHERE id = ?',
+                       array( $id )
+               );
+               return $row &&
+                       Password::hashEquals( $row['reset_hash'], $hash ) &&
+                       // Tokens are only good for 48 hours
+                       ( time() - strtotime( $row['reset_date'] ) ) < 172800;
+       }
+
+       /**
+        * Reset a user's password after validating the reset token.
+        *
+        * @param int $id User id
+        * @param string $token Reset token
+        * @param string $pass New password
+        * @return bool
+        */
+       public function resetPassword( $id, $token, $pass ) {
+               $ret = false;
+               if ( $this->validatePasswordResetToken( $id, $token ) ) {
+                       $ret = $this->updatePassword( null, $pass, $id, true );
+                       if ( $ret ) {
+                               // Consume token if change was successful
+                               $this->updatePasswordResetHash( $id, null );
+                       }
+               }
+               return $ret;
+       }
 }
diff --git a/src/Password.php b/src/Password.php
index 589aac8..0a86fb9 100644
--- a/src/Password.php
+++ b/src/Password.php
@@ -54,7 +54,7 @@
                        $check = md5( $plainText );
                }
 
-               return $check === $hash;
+               return self::hashEquals( $hash, $check );
        }
 
 
@@ -243,6 +243,46 @@
 
 
        /**
+        * Check whether a user-provided string is equal to a fixed-length 
secret
+        * string without revealing bytes of the secret string through timing
+        * differences.
+        *
+        * Implementation for PHP deployments which do not natively have
+        * hash_equals taken from MediaWiki's hash_equals() polyfill function.
+        *
+        * @param string $known Fixed-length secret string to compare against
+        * @param string $input User-provided string
+        * @return bool True if the strings are the same, false otherwise
+        */
+       public static function hashEquals( $known, $input ) {
+               if ( function_exists( 'hash_equals' ) ) {
+                       return hash_equals( $known, $input );
+
+               } else {
+                       // hash_equals() polyfill taken from MediaWiki
+                       if ( !is_string( $known ) ) {
+                               return false;
+                       }
+                       if ( !is_string( $input ) ) {
+                               return false;
+                       }
+
+                       $len = strlen( $known );
+                       if ( $len !== strlen( $input ) ) {
+                               return false;
+                       }
+
+                       $result = 0;
+                       for ( $i = 0; $i < $len; $i++ ) {
+                               $result |= ord( $known[$i] ) ^ ord( $input[$i] 
);
+                       }
+
+                       return $result === 0;
+               }
+       }
+
+
+       /**
         * Construction of utility class is not allowed.
         */
        private function __construct() {
diff --git a/tests/PasswordTest.php b/tests/PasswordTest.php
index cb86668..ec5c777 100644
--- a/tests/PasswordTest.php
+++ b/tests/PasswordTest.php
@@ -43,6 +43,7 @@
 
        /**
         * @covers ::comparePasswordToHash
+        * @covers ::hashEquals
         */
        public function testComparePasswordToHash() {
                $enc = Password::encodePassword( 'password' );
@@ -61,4 +62,15 @@
                $this->assertEquals( 16, strlen( $p ) );
        }
 
+       /**
+        * @covers ::hashEquals
+        */
+       public function testHashEquals() {
+               $this->assertFalse( Password::hashEquals( false, '' ) );
+               $this->assertFalse( Password::hashEquals( '', false ) );
+               $this->assertFalse( Password::hashEquals( 'a', '' ) );
+               $this->assertFalse( Password::hashEquals( 'a', 'b' ) );
+               $this->assertTrue( Password::hashEquals( 'a', 'a' ) );
+       }
+
 } //end PasswordTest

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

Gerrit-MessageType: merged
Gerrit-Change-Id: I0eb3aa722954c8cc9bc395f427aee1beaa80c2e1
Gerrit-PatchSet: 6
Gerrit-Project: wikimedia/iegreview
Gerrit-Branch: master
Gerrit-Owner: BryanDavis <[email protected]>
Gerrit-Reviewer: BryanDavis <[email protected]>
Gerrit-Reviewer: CSteipp <[email protected]>
Gerrit-Reviewer: Niharika29 <[email protected]>
Gerrit-Reviewer: Siebrand <[email protected]>
Gerrit-Reviewer: Springle <[email protected]>
Gerrit-Reviewer: jenkins-bot <>

_______________________________________________
MediaWiki-commits mailing list
[email protected]
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits

Reply via email to