Passed
Pull Request — master (#373)
by MusikAnimal
11:46
created

AutomatedEditsHelper::isAutomated()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

Changes 0
Metric Value
cc 1
eloc 1
nc 1
nop 2
dl 0
loc 3
ccs 2
cts 2
cp 1
crap 1
rs 10
c 0
b 0
f 0
1
<?php
2
/**
3
 * This file contains only the AutomatedEditsHelper class.
4
 */
5
6
declare(strict_types = 1);
7
8
namespace AppBundle\Helper;
9
10
use AppBundle\Model\Project;
11
use DateInterval;
12
use Psr\Cache\CacheItemPoolInterface;
13
use Symfony\Component\DependencyInjection\ContainerInterface;
14
15
/**
16
 * Helper class for fetching semi-automated definitions.
17
 */
18
class AutomatedEditsHelper
19
{
20
    /** @var array The list of tools that are considered reverting. */
21
    protected $revertTools = [];
22
23
    /** @var array The list of tool names and their regexes/tags. */
24
    protected $tools = [];
25
26
    /** @var ContainerInterface */
27
    private $container;
28
29
    /** @var CacheItemPoolInterface */
30
    protected $cache;
31
32
    /**
33
     * AutomatedEditsHelper constructor.
34
     * @param ContainerInterface $container
35
     */
36 14
    public function __construct(ContainerInterface $container)
37
    {
38 14
        $this->container = $container;
39 14
        $this->cache = $container->get('cache.app');
40 14
    }
41
42
    /**
43
     * Get the tool that matched the given edit summary.
44
     * This only works for tools defined with regular expressions, not tags.
45
     * @param string $summary Edit summary
46
     * @param Project $project
47
     * @return string[]|false Tool entry including key for 'name', or false if nothing was found
48
     */
49 9
    public function getTool(string $summary, Project $project)
50
    {
51 9
        foreach ($this->getTools($project) as $tool => $values) {
52 9
            if (isset($values['regex']) && preg_match('/'.$values['regex'].'/', $summary)) {
53 9
                return array_merge([
54 9
                    'name' => $tool,
55 9
                ], $values);
56
            }
57
        }
58
59 7
        return false;
60
    }
61
62
    /**
63
     * Was the edit (semi-)automated, based on the edit summary?
64
     * This only works for tools defined with regular expressions, not tags.
65
     * @param string $summary Edit summary
66
     * @param Project $project
67
     * @return bool
68
     */
69 1
    public function isAutomated(string $summary, Project $project): bool
70
    {
71 1
        return (bool)$this->getTool($summary, $project);
72
    }
73
74
    /**
75
     * Fetch the config from https://meta.wikimedia.org/wiki/MediaWiki:XTools-AutoEdits.json
76
     * @param bool $useSandbox Use the sandbox version of the config, located at MediaWiki:XTools-AutoEdits.json/sandbox
77
     * @return array
78
     */
79 14
    public function getConfig(bool $useSandbox = false): array
80
    {
81 14
        $cacheKey = 'autoedits_config';
82 14
        if (!$useSandbox && $this->cache->hasItem($cacheKey)) {
83 13
            return $this->cache->getItem($cacheKey)->get();
84
        }
85
86 1
        $title = 'MediaWiki:XTools-AutoEdits.json' . ($useSandbox ? '/sandbox' : '');
87 1
        $ret = json_decode(file_get_contents(
88 1
            "https://meta.wikimedia.org/w/index.php?action=raw&ctype=application/json&title=$title"
89 1
        ), true);
90
91 1
        if (!$useSandbox) {
92 1
            $cacheItem = $this->cache
93 1
                ->getItem($cacheKey)
94 1
                ->set($ret)
95 1
                ->expiresAfter(new DateInterval('PT20M'));
96 1
            $this->cache->save($cacheItem);
97
        }
98
99 1
        return $ret;
100
    }
101
102
    /**
103
     * Get list of automated tools and their associated info for the given project.
104
     * This defaults to the 'default_project' if entries for the given project are not found.
105
     * @param Project $project
106
     * @param bool $useSandbox Whether to use the /sandbox version for testing (also bypasses caching).
107
     * @return array Each tool with the tool name as the key and 'link', 'regex' and/or 'tag' as the subarray keys.
108
     */
109 14
    public function getTools(Project $project, bool $useSandbox = false): array
110
    {
111 14
        $projectDomain = $project->getDomain();
112
113 14
        if (isset($this->tools[$projectDomain])) {
114 7
            return $this->tools[$projectDomain];
115
        }
116
117
        // Load the semi-automated edit types.
118 14
        $tools = $this->getConfig($useSandbox);
119
120 14
        if (isset($tools[$projectDomain])) {
121 14
            $localRules = $tools[$projectDomain];
122
        } else {
123
            $localRules = [];
124
        }
125
126 14
        $langRules = $tools[$project->getLang()] ?? [];
127
128
        // Per-wiki rules have priority, followed by language-specific and global.
129 14
        $globalWithLangRules = $this->mergeRules($tools['global'], $langRules);
130
131 14
        $this->tools[$projectDomain] = $this->mergeRules(
132 14
            $globalWithLangRules,
133 14
            $localRules
134
        );
135
136
        // Once last walk through for some tidying up and validation.
137 14
        $invalid = [];
138 14
        array_walk($this->tools[$projectDomain], function (&$data, $tool) use (&$invalid): void {
139
            // Populate the 'label' with the tool name, if a label doesn't already exist.
140 14
            $data['label'] = $data['label'] ?? $tool;
141
142
            // 'namespaces' should be an array of ints.
143 14
            $data['namespaces'] = $data['namespaces'] ?? [];
144 14
            if (isset($data['namespace'])) {
145
                $data['namespaces'][] = $data['namespace'];
146
                unset($data['namespace']);
147
            }
148
149
            // 'tags' should be an array of strings.
150 14
            $data['tags'] = $data['tags'] ?? [];
151 14
            if (isset($data['tag'])) {
152
                $data['tags'][] = $data['tag'];
153
                unset($data['tag']);
154
            }
155
156
            // If neither a tag or regex is given, it's invalid.
157 14
            if (empty($data['tags']) && empty($data['regex'])) {
158
                $invalid[] = $tool;
159
            }
160 14
        });
161
162 14
        uksort($this->tools[$projectDomain], 'strcasecmp');
163
164 14
        if ($invalid) {
165
            $this->tools[$projectDomain]['invalid'] = $invalid;
166
        }
167
168 14
        return $this->tools[$projectDomain];
169
    }
170
171
    /**
172
     * Merges the given rule sets, giving priority to the local set. Regex is concatenated, not overridden.
173
     * @param string[] $globalRules The global rule set.
174
     * @param string[] $localRules The rule set for the local wiki.
175
     * @return string[] Merged rules.
176
     */
177 14
    private function mergeRules(array $globalRules, array $localRules): array
178
    {
179
        // Initial set, including just the global rules.
180 14
        $tools = $globalRules;
181
182
        // Loop through local rules and override/merge as necessary.
183 14
        foreach ($localRules as $tool => $rules) {
184 14
            $newRules = $rules;
185
186 14
            if (isset($globalRules[$tool])) {
187
                // Order within array_merge is important, so that local rules get priority.
188 14
                $newRules = array_merge($globalRules[$tool], $rules);
0 ignored issues
show
Bug introduced by
$rules of type string is incompatible with the type array|null expected by parameter $array2 of array_merge(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

188
                $newRules = array_merge($globalRules[$tool], /** @scrutinizer ignore-type */ $rules);
Loading history...
Bug introduced by
$globalRules[$tool] of type string is incompatible with the type array expected by parameter $array1 of array_merge(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

188
                $newRules = array_merge(/** @scrutinizer ignore-type */ $globalRules[$tool], $rules);
Loading history...
189
            }
190
191
            // Regex should be merged, not overridden.
192 14
            if (isset($rules['regex']) && isset($globalRules[$tool]['regex'])) {
193 1
                $newRules['regex'] = implode('|', [
194 1
                    $rules['regex'],
195 1
                    $globalRules[$tool]['regex'],
196
                ]);
197
            }
198
199 14
            $tools[$tool] = $newRules;
200
        }
201
202 14
        return $tools;
203
    }
204
205
    /**
206
     * Get only tools that are used to revert edits.
207
     * Revert detection happens only by testing against a regular expression, and not by checking tags.
208
     * @param Project $project
209
     * @return string[][] Each tool with the tool name as the key,
210
     *   and 'link' and 'regex' as the subarray keys.
211
     */
212 7
    public function getRevertTools(Project $project): array
213
    {
214 7
        $projectDomain = $project->getDomain();
215
216 7
        if (isset($this->revertTools[$projectDomain])) {
217 7
            return $this->revertTools[$projectDomain];
218
        }
219
220 7
        $revertEntries = array_filter(
221 7
            $this->getTools($project),
222 7
            function ($tool) {
223 7
                return isset($tool['revert']) && isset($tool['regex']);
224 7
            }
225
        );
226
227
        // If 'revert' is set to `true`, then use 'regex' as the regular expression,
228
        //  otherwise 'revert' is assumed to be the regex string.
229 7
        $this->revertTools[$projectDomain] = array_map(function ($revertTool) {
230
            return [
231 7
                'link' => $revertTool['link'],
232 7
                'regex' => true === $revertTool['revert'] ? $revertTool['regex'] : $revertTool['revert'],
233
            ];
234 7
        }, $revertEntries);
235
236 7
        return $this->revertTools[$projectDomain];
237
    }
238
239
    /**
240
     * Was the edit a revert, based on the edit summary?
241
     * This only works for tools defined with regular expressions, not tags.
242
     * @param string|null $summary Edit summary. Can be null for instance for suppressed edits.
243
     * @param Project $project
244
     * @return bool
245
     */
246 7
    public function isRevert(?string $summary, Project $project): bool
247
    {
248 7
        foreach (array_values($this->getRevertTools($project)) as $values) {
249 7
            if (preg_match('/'.$values['regex'].'/', (string)$summary)) {
250 7
                return true;
251
            }
252
        }
253
254 7
        return false;
255
    }
256
}
257