Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 94
0.00% covered (danger)
0.00%
0 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
FixSuggestedEditChangeTags
0.00% covered (danger)
0.00%
0 / 88
0.00% covered (danger)
0.00%
0 / 5
240
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 initialize
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
30
 execute
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
12
 getStructuredEditTagsQuery
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
2
 processRow
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2
3namespace GrowthExperiments\Maintenance;
4
5use ChangeTags;
6use GrowthExperiments\GrowthExperimentsServices;
7use GrowthExperiments\NewcomerTasks\TaskType\ImageRecommendationTaskTypeHandler;
8use GrowthExperiments\NewcomerTasks\TaskType\LinkRecommendationTaskTypeHandler;
9use GrowthExperiments\NewcomerTasks\TaskType\StructuredTaskTypeHandler;
10use GrowthExperiments\NewcomerTasks\TaskType\TaskType;
11use GrowthExperiments\NewcomerTasks\TaskType\TaskTypeHandler;
12use Maintenance;
13use MediaWiki\MediaWikiServices;
14use MediaWiki\Revision\RevisionStore;
15use MediaWiki\Status\Status;
16use MediaWiki\User\User;
17use StatusValue;
18use stdClass;
19use Wikimedia\Rdbms\IReadableDatabase;
20use Wikimedia\Rdbms\SelectQueryBuilder;
21
22$IP = getenv( 'MW_INSTALL_PATH' );
23if ( $IP === false ) {
24    $IP = __DIR__ . '/../../..';
25}
26require_once "$IP/maintenance/Maintenance.php";
27
28class FixSuggestedEditChangeTags extends Maintenance {
29
30    /** @var IReadableDatabase */
31    private $dbr;
32
33    /** @var RevisionStore */
34    private $revisionStore;
35
36    /** @var User User to perform the actions with. */
37    private $user;
38
39    /** @var TaskType The task type to work on. */
40    private $taskType;
41
42    /** @var TaskTypeHandler The handler of task type to work on. */
43    private $taskTypeHandler;
44
45    /** @var string The log_action value associated with this task type. */
46    private $logAction;
47
48    /** @var string The change tag name associated with this task type. */
49    private $changeTagName;
50
51    /** @var int The change tag ID associated with this task type. */
52    private $changeTagId;
53
54    public function __construct() {
55        parent::__construct();
56        $this->requireExtension( 'GrowthExperiments' );
57        $this->addDescription( 'Remove change tags which were added in error (T296818)' );
58        $this->addOption( 'tasktype', 'Task type', true, true );
59        $this->addOption( 'from', 'Revision ID to continue from', false, true );
60        $this->addOption( 'fix', 'Make changes (default is dry run)' );
61        $this->addOption( 'verbose', 'Verbose mode (list fixed titles)', false, false, 'v' );
62        $this->setBatchSize( 100 );
63    }
64
65    private function initialize() {
66        $services = MediaWikiServices::getInstance();
67        $growthServices = GrowthExperimentsServices::wrap( $services );
68
69        $this->dbr = $this->getDB( DB_REPLICA );
70        $this->revisionStore = $services->getRevisionStore();
71        $this->user = User::newSystemUser( User::MAINTENANCE_SCRIPT_USER, [ 'steal' => true ] );
72
73        $taskTypeId = $this->getOption( 'tasktype' );
74        $configurationLoader = $growthServices->getNewcomerTasksConfigurationLoader();
75        $taskTypes = $configurationLoader->getTaskTypes() ?: $configurationLoader->loadTaskTypes();
76        if ( $taskTypes instanceof StatusValue ) {
77            $this->fatalError( Status::wrap( $taskTypes )->getWikiText( false, false, 'en' ) );
78        } elseif ( !array_key_exists( $taskTypeId, $taskTypes ) ) {
79            $this->fatalError( "Invalid task type $taskTypeId" );
80        }
81        $this->taskType = $taskTypes[$taskTypeId];
82        $this->taskTypeHandler = $growthServices->getTaskTypeHandlerRegistry()
83            ->getByTaskType( $this->taskType );
84        if ( !( $this->taskTypeHandler instanceof StructuredTaskTypeHandler ) ) {
85            $this->fatalError( "$taskTypeId is not a structured task type" );
86        }
87
88        $this->logAction = [
89            'link-recommendation' => 'addlink',
90            'image-recommendation' => 'addimage',
91        ][$taskTypeId];
92        $this->changeTagName = [
93            'link-recommendation' => LinkRecommendationTaskTypeHandler::CHANGE_TAG,
94            'image-recommendation' => ImageRecommendationTaskTypeHandler::CHANGE_TAG,
95        ][$taskTypeId];
96        $changeTagDefStore = $services->getChangeTagDefStore();
97        $this->changeTagId = $changeTagDefStore->getId( $this->changeTagName );
98    }
99
100    /** @inheritDoc */
101    public function execute() {
102        $this->initialize();
103        $fromRevision = (int)$this->getOption( 'from', 0 );
104
105        $fixedRevisions = 0;
106        do {
107            $this->output( "Fetching batch from revision $fromRevision\n" );
108            $queryBuilder = $this->getStructuredEditTagsQuery( $this->dbr, $this->getBatchSize(),
109                $fromRevision );
110            $res = $queryBuilder->fetchResultSet();
111            foreach ( $res as $row ) {
112                $this->processRow( $row, $fixedRevisions );
113                $fromRevision = $row->rev_id + 1;
114            }
115            $this->waitForReplication();
116        } while ( $res->numRows() );
117        $this->output(
118            ( $this->hasOption( 'fix' ) ? 'Fixed' : 'Would have fixed' )
119            . " $fixedRevisions revisions\n"
120        );
121    }
122
123    /**
124     * Get a query for (one page of) all revisions with structured edit change tags.
125     * @param IReadableDatabase $dbr
126     * @param int $limit Number of rows the query should return.
127     * @param int $fromRevision Revision to start from (ascending).
128     * @return SelectQueryBuilder
129     */
130    private function getStructuredEditTagsQuery(
131        IReadableDatabase $dbr,
132        int $limit,
133        int $fromRevision
134    ): SelectQueryBuilder {
135        // Get a basic revision query.
136        $queryInfo = $this->revisionStore->getQueryInfo();
137        $queryBuilder = $dbr->newSelectQueryBuilder()->queryInfo( $queryInfo );
138
139        // Join with change tags and filter to the selected tag.
140        // ChangeTags::modifyDisplayQuery() would make a complicated query to concatenate all
141        // tags; we don't need that so not worth using it.
142        $queryBuilder->join( 'change_tag', null, [
143            'rev_id = ct_rev_id',
144            'ct_tag_id' => $this->changeTagId,
145        ] );
146
147        // Join with the relevant log events.
148        // We only associate the log event with the revision on edit, not rejection, so
149        // this will be a one-to-one relationship.
150        $queryBuilder->leftJoin(
151            $queryBuilder->newJoinGroup()
152                ->table( 'logging' )
153                ->join( 'log_search', null, [
154                    'ls_log_id = log_id',
155                    'ls_field' => 'associated_rev_id',
156                ] ),
157            null,
158            [
159                'ls_value = rev_id',
160                'log_type' => 'growthexperiments',
161                'log_action' => [ 'addlink', 'addimage' ],
162            ]
163        );
164        $queryBuilder->field( 'log_action' );
165
166        // Handle paging.
167        // We have to make use of the rather unhelpful (ct_tag_id, ct_rc_id, ct_rev_id, ct_log_id)
168        // index, otherwise the query would have to go through all revisions.
169        // We can choose between paginating by revision ID, which is a filesort, or paginating
170        // by RC id, which makes the --from flag less useful. For now we go with the first
171        // as the number of tagged revisions is expected to be small.
172        $queryBuilder->conds( 'ct_rev_id >= ' . $fromRevision );
173        $queryBuilder->orderBy( 'ct_tag_id ASC, ct_rev_id ASC' );
174        $queryBuilder->limit( $limit );
175
176        return $queryBuilder;
177    }
178
179    private function processRow( stdClass $row, int &$fixedRevisions ) {
180        if ( $row->log_action != $this->logAction ) {
181            // Revision with a structured edit change tag but no associated log entry.
182            // Log entries are way more reliable (and we couldn't retroactively change log entries
183            // anyway), remove the change tag.
184            if ( $this->hasOption( 'fix' ) ) {
185                ChangeTags::updateTags( null, $this->changeTagName, $rc_id, $row->rev_id, $log_id,
186                    null, null, $this->user );
187            }
188            if ( $this->hasOption( 'verbose' ) ) {
189                $diffLink = wfExpandUrl( wfAppendQuery( wfScript(), [
190                    'oldid' => $row->rev_id,
191                    'diff' => 'prev',
192                ] ), PROTO_CANONICAL );
193                $verb = $this->hasOption( 'fix' ) ? 'Removing' : 'Would remove';
194                $this->output( "$verb tag for $diffLink\n" );
195            }
196            $fixedRevisions++;
197        }
198    }
199
200}
201
202$maintClass = FixSuggestedEditChangeTags::class;
203require_once RUN_MAINTENANCE_IF_MAIN;