Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.87% covered (success)
97.87%
92 / 94
50.00% covered (danger)
50.00%
2 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
ReassignEdits
97.87% covered (success)
97.87%
92 / 94
50.00% covered (danger)
50.00%
2 / 4
18
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 execute
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
6.02
 doReassignEdits
98.48% covered (success)
98.48%
65 / 66
0.00% covered (danger)
0.00%
0 / 1
8
 initialiseUser
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2/**
3 * Reassign edits from a user or IP address to another user
4 *
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 * http://www.gnu.org/copyleft/gpl.html
19 *
20 * @file
21 * @ingroup Maintenance
22 * @author Rob Church <robchur@gmail.com>
23 * @license GPL-2.0-or-later
24 */
25
26use MediaWiki\Maintenance\Maintenance;
27use MediaWiki\User\User;
28use Wikimedia\IPUtils;
29
30// @codeCoverageIgnoreStart
31require_once __DIR__ . '/Maintenance.php';
32// @codeCoverageIgnoreEnd
33
34/**
35 * Maintenance script that reassigns edits from a user or IP address
36 * to another user.
37 *
38 * @ingroup Maintenance
39 */
40class ReassignEdits extends Maintenance {
41    public function __construct() {
42        parent::__construct();
43        $this->addDescription( 'Reassign edits from one user to another' );
44        $this->addOption( "force", "Reassign even if the target user doesn't exist" );
45        $this->addOption( "norc", "Don't update the recent changes table" );
46        $this->addOption( "report", "Print out details of what would be changed, but don't update it" );
47        $this->addArg( 'from', 'Old user to take edits from' );
48        $this->addArg( 'to', 'New user to give edits to' );
49    }
50
51    public function execute() {
52        # Set up the users involved
53        $from = $this->initialiseUser( $this->getArg( 0 ) );
54        $to = $this->initialiseUser( $this->getArg( 1 ) );
55
56        // Reject attempts to re-assign to an IP address. This is done because the script does not
57        // populate ip_changes and it breaks if temporary accounts are enabled (T373914).
58        if ( IPUtils::isIPAddress( $to->getName() ) ) {
59            $this->fatalError( 'Script does not support re-assigning to another IP.' );
60        } elseif ( $from->equals( $to ) ) {
61            $this->fatalError( 'The from and to user cannot be the same.' );
62        }
63
64        # If the target doesn't exist, and --force is not set, stop here
65        if ( $to->getId() || $this->hasOption( 'force' ) ) {
66            # Reassign the edits
67            $report = $this->hasOption( 'report' );
68            $this->doReassignEdits( $from, $to, !$this->hasOption( 'norc' ), $report );
69            # If reporting, and there were items, advise the user to run without --report
70            if ( $report ) {
71                $this->output( "Run the script again without --report to update.\n" );
72            }
73        } else {
74            $this->fatalError( "User '{$to->getName()}' not found." );
75        }
76    }
77
78    /**
79     * Reassign edits from one user to another
80     *
81     * @param User &$from User to take edits from
82     * @param User &$to User to assign edits to
83     * @param bool $updateRC Update the recent changes table
84     * @param bool $report Don't change things; just echo numbers
85     * @return int The number of entries changed, or that would be changed
86     */
87    private function doReassignEdits( &$from, &$to, $updateRC = false, $report = false ) {
88        $dbw = $this->getPrimaryDB();
89        $this->beginTransaction( $dbw, __METHOD__ );
90        $actorNormalization = $this->getServiceContainer()->getActorNormalization();
91        $fromActorId = $actorNormalization->findActorId( $from, $dbw );
92
93        # Count things
94        $this->output( "Checking current edits..." );
95
96        $revisionRows = $dbw->newSelectQueryBuilder()
97            ->select( '*' )
98            ->from( 'revision' )
99            ->where( [ 'rev_actor' => $fromActorId ] )
100            ->caller( __METHOD__ )
101            ->fetchRowCount();
102
103        $this->output( "found {$revisionRows}.\n" );
104
105        $this->output( "Checking deleted edits..." );
106        $archiveRows = $dbw->newSelectQueryBuilder()
107            ->select( '*' )
108            ->from( 'archive' )
109            ->where( [ 'ar_actor' => $fromActorId ] )
110            ->caller( __METHOD__ )->fetchRowCount();
111        $this->output( "found {$archiveRows}.\n" );
112
113        # Don't count recent changes if we're not supposed to
114        if ( $updateRC ) {
115            $this->output( "Checking recent changes..." );
116            $recentChangesRows = $dbw->newSelectQueryBuilder()
117                ->select( '*' )
118                ->from( 'recentchanges' )
119                ->where( [ 'rc_actor' => $fromActorId ] )
120                ->caller( __METHOD__ )->fetchRowCount();
121            $this->output( "found {$recentChangesRows}.\n" );
122        } else {
123            $recentChangesRows = 0;
124        }
125
126        $total = $revisionRows + $archiveRows + $recentChangesRows;
127        $this->output( "\nTotal entries to change: {$total}\n" );
128
129        $toActorId = $actorNormalization->acquireActorId( $to, $dbw );
130        if ( !$report && $total ) {
131            $this->output( "\n" );
132            if ( $revisionRows ) {
133                # Reassign edits
134                $this->output( "Reassigning current edits..." );
135
136                $dbw->newUpdateQueryBuilder()
137                    ->update( 'revision' )
138                    ->set( [ 'rev_actor' => $toActorId ] )
139                    ->where( [ 'rev_actor' => $fromActorId ] )
140                    ->caller( __METHOD__ )->execute();
141
142                $this->output( "done.\n" );
143            }
144
145            if ( $archiveRows ) {
146                $this->output( "Reassigning deleted edits..." );
147
148                $dbw->newUpdateQueryBuilder()
149                    ->update( 'archive' )
150                    ->set( [ 'ar_actor' => $toActorId ] )
151                    ->where( [ 'ar_actor' => $fromActorId ] )
152                    ->caller( __METHOD__ )->execute();
153
154                $this->output( "done.\n" );
155            }
156            # Update recent changes if required
157            if ( $recentChangesRows ) {
158                $this->output( "Updating recent changes..." );
159
160                $dbw->newUpdateQueryBuilder()
161                    ->update( 'recentchanges' )
162                    ->set( [ 'rc_actor' => $toActorId ] )
163                    ->where( [ 'rc_actor' => $fromActorId ] )
164                    ->caller( __METHOD__ )->execute();
165
166                $this->output( "done.\n" );
167            }
168
169            # If $from is an IP, delete any relevant rows from the
170            # ip_changes. No update needed, as $to cannot be an IP.
171            if ( !$from->isRegistered() ) {
172                $this->output( "Deleting ip_changes..." );
173
174                $dbw->newDeleteQueryBuilder()
175                    ->deleteFrom( 'ip_changes' )
176                    ->where( [ 'ipc_hex' => IPUtils::toHex( $from->getName() ) ] )
177                    ->caller( __METHOD__ )->execute();
178
179                $this->output( "done.\n" );
180            }
181        }
182
183        $this->commitTransaction( $dbw, __METHOD__ );
184
185        return $total;
186    }
187
188    /**
189     * Initialise the user object
190     *
191     * @param string $username Username or IP address
192     * @return User
193     */
194    private function initialiseUser( $username ) {
195        $services = $this->getServiceContainer();
196        if ( $services->getUserNameUtils()->isIP( $username ) ) {
197            $user = User::newFromName( $username, false );
198            $user->getActorId();
199        } else {
200            $user = User::newFromName( $username );
201            if ( !$user ) {
202                $this->fatalError( "Invalid username" );
203            }
204        }
205        $user->load();
206
207        return $user;
208    }
209}
210
211// @codeCoverageIgnoreStart
212$maintClass = ReassignEdits::class;
213require_once RUN_MAINTENANCE_IF_MAIN;
214// @codeCoverageIgnoreEnd