1
|
|
|
<?php |
2
|
|
|
/** |
3
|
|
|
* This file is part of the SVN-Buddy library. |
4
|
|
|
* For the full copyright and license information, please view |
5
|
|
|
* the LICENSE file that was distributed with this source code. |
6
|
|
|
* |
7
|
|
|
* @copyright Alexander Obuhovich <[email protected]> |
8
|
|
|
* @link https://github.com/console-helpers/svn-buddy |
9
|
|
|
*/ |
10
|
|
|
|
11
|
|
|
namespace ConsoleHelpers\SVNBuddy\Repository\RevisionLog\Plugin\DatabaseCollectorPlugin; |
12
|
|
|
|
13
|
|
|
|
14
|
|
|
use Aura\Sql\ExtendedPdoInterface; |
15
|
|
|
use ConsoleHelpers\SVNBuddy\Repository\Connector\Connector; |
16
|
|
|
use ConsoleHelpers\SVNBuddy\Repository\Parser\LogMessageParserFactory; |
17
|
|
|
use ConsoleHelpers\SVNBuddy\Repository\RevisionLog\Plugin\IOverwriteAwarePlugin; |
18
|
|
|
use ConsoleHelpers\SVNBuddy\Repository\RevisionLog\Plugin\TOverwriteAwarePlugin; |
19
|
|
|
use ConsoleHelpers\SVNBuddy\Repository\RevisionLog\RepositoryFiller; |
20
|
|
|
|
21
|
|
|
class BugsPlugin extends AbstractDatabaseCollectorPlugin implements IOverwriteAwarePlugin |
22
|
|
|
{ |
23
|
|
|
|
24
|
|
|
use TOverwriteAwarePlugin; |
25
|
|
|
|
26
|
|
|
const STATISTIC_BUG_ADDED_TO_COMMIT = 'bug_added_to_commit'; |
27
|
|
|
|
28
|
|
|
const STATISTIC_BUG_REMOVED_FROM_COMMIT = 'bug_removed_from_commit'; |
29
|
|
|
|
30
|
|
|
/** |
31
|
|
|
* Repository url. |
32
|
|
|
* |
33
|
|
|
* @var string |
34
|
|
|
*/ |
35
|
|
|
private $_repositoryUrl; |
36
|
|
|
|
37
|
|
|
/** |
38
|
|
|
* Repository connector. |
39
|
|
|
* |
40
|
|
|
* @var Connector |
41
|
|
|
*/ |
42
|
|
|
private $_repositoryConnector; |
43
|
|
|
|
44
|
|
|
/** |
45
|
|
|
* Log message parser factory. |
46
|
|
|
* |
47
|
|
|
* @var LogMessageParserFactory |
48
|
|
|
*/ |
49
|
|
|
private $_logMessageParserFactory; |
50
|
|
|
|
51
|
|
|
/** |
52
|
|
|
* Create bugs revision log plugin. |
53
|
|
|
* |
54
|
|
|
* @param ExtendedPdoInterface $database Database. |
55
|
|
|
* @param RepositoryFiller $repository_filler Repository filler. |
56
|
|
|
* @param string $repository_url Repository url. |
57
|
|
|
* @param Connector $repository_connector Repository connector. |
58
|
|
|
* @param LogMessageParserFactory $log_message_parser_factory Log message parser. |
59
|
|
|
*/ |
60
|
24 |
|
public function __construct( |
61
|
|
|
ExtendedPdoInterface $database, |
62
|
|
|
RepositoryFiller $repository_filler, |
63
|
|
|
$repository_url, |
64
|
|
|
Connector $repository_connector, |
65
|
|
|
LogMessageParserFactory $log_message_parser_factory |
66
|
|
|
) { |
67
|
24 |
|
parent::__construct($database, $repository_filler); |
68
|
|
|
|
69
|
24 |
|
$this->_repositoryUrl = $repository_url; |
70
|
24 |
|
$this->_repositoryConnector = $repository_connector; |
71
|
24 |
|
$this->_logMessageParserFactory = $log_message_parser_factory; |
72
|
24 |
|
} |
73
|
|
|
|
74
|
|
|
/** |
75
|
|
|
* Returns plugin name. |
76
|
|
|
* |
77
|
|
|
* @return string |
78
|
|
|
*/ |
79
|
17 |
|
public function getName() |
80
|
|
|
{ |
81
|
17 |
|
return 'bugs'; |
82
|
|
|
} |
83
|
|
|
|
84
|
|
|
/** |
85
|
|
|
* Defines parsing statistic types. |
86
|
|
|
* |
87
|
|
|
* @return array |
88
|
|
|
*/ |
89
|
24 |
|
public function defineStatisticTypes() |
90
|
|
|
{ |
91
|
|
|
return array( |
92
|
24 |
|
self::STATISTIC_BUG_ADDED_TO_COMMIT, self::STATISTIC_BUG_REMOVED_FROM_COMMIT, |
93
|
|
|
); |
94
|
|
|
} |
95
|
|
|
|
96
|
|
|
/** |
97
|
|
|
* Processes data. |
98
|
|
|
* |
99
|
|
|
* @param integer $from_revision From revision. |
100
|
|
|
* @param integer $to_revision To revision. |
101
|
|
|
* |
102
|
|
|
* @return void |
103
|
|
|
*/ |
104
|
13 |
|
public function doProcess($from_revision, $to_revision) |
105
|
|
|
{ |
106
|
13 |
|
$this->populateMissingBugRegExp(); |
107
|
|
|
|
108
|
13 |
|
$last_revision = $this->getLastRevision(); |
109
|
|
|
|
110
|
13 |
|
if ( $this->isOverwriteMode() ) { |
111
|
1 |
|
$this->remove($from_revision, $to_revision); |
112
|
1 |
|
$this->detectBugs($from_revision, $to_revision); |
113
|
|
|
} |
114
|
12 |
|
elseif ( $to_revision > $last_revision ) { |
115
|
7 |
|
$this->detectBugs($last_revision + 1, $to_revision); |
116
|
|
|
} |
117
|
|
|
|
118
|
13 |
|
if ( $to_revision > $last_revision ) { |
119
|
7 |
|
$this->setLastRevision($to_revision); |
120
|
|
|
} |
121
|
13 |
|
} |
122
|
|
|
|
123
|
|
|
/** |
124
|
|
|
* Removes changes plugin made based on a given revision. |
125
|
|
|
* |
126
|
|
|
* @param integer $from_revision From revision. |
127
|
|
|
* @param integer $to_revision To revision. |
128
|
|
|
* |
129
|
|
|
* @return void |
130
|
|
|
*/ |
131
|
1 |
|
protected function remove($from_revision, $to_revision) |
132
|
|
|
{ |
133
|
1 |
|
for ( $revision = $from_revision; $revision <= $to_revision; $revision++ ) { |
134
|
1 |
|
$bug_count = $this->repositoryFiller->removeBugsFromCommit($revision); |
135
|
1 |
|
$this->recordStatistic(self::STATISTIC_BUG_REMOVED_FROM_COMMIT, $bug_count); |
136
|
|
|
} |
137
|
1 |
|
} |
138
|
|
|
|
139
|
|
|
/** |
140
|
|
|
* Populate "BugRegExp" column for projects without it. |
141
|
|
|
* |
142
|
|
|
* @param boolean $cache_overwrite Overwrite used "bugtraq:logregex" SVN property's cached value. |
143
|
|
|
* |
144
|
|
|
* @return void |
145
|
|
|
*/ |
146
|
13 |
|
protected function populateMissingBugRegExp($cache_overwrite = false) |
147
|
|
|
{ |
148
|
13 |
|
$projects = $this->getProjects('BugRegExp IS NULL'); |
149
|
|
|
|
150
|
13 |
|
if ( !$projects ) { |
151
|
7 |
|
$this->advanceProgressBar(); |
152
|
|
|
|
153
|
7 |
|
return; |
154
|
|
|
} |
155
|
|
|
|
156
|
6 |
|
foreach ( $projects as $project_data ) { |
157
|
6 |
|
$bug_regexp = $this->detectProjectBugTraqRegEx( |
158
|
6 |
|
$project_data['Path'], |
159
|
6 |
|
$project_data['RevisionLastSeen'], |
160
|
6 |
|
(bool)$project_data['IsDeleted'], |
161
|
6 |
|
$cache_overwrite |
162
|
|
|
); |
163
|
|
|
|
164
|
6 |
|
$this->repositoryFiller->setProjectBugRegexp($project_data['Id'], $bug_regexp); |
165
|
6 |
|
$this->advanceProgressBar(); |
166
|
|
|
} |
167
|
6 |
|
} |
168
|
|
|
|
169
|
|
|
/** |
170
|
|
|
* Determines project bug tracking regular expression. |
171
|
|
|
* |
172
|
|
|
* @param string $project_path Project project_path. |
173
|
|
|
* @param integer $revision Revision. |
174
|
|
|
* @param boolean $project_deleted Project is deleted. |
175
|
|
|
* @param boolean $cache_overwrite Overwrite used "bugtraq:logregex" SVN property's cached value. |
176
|
|
|
* |
177
|
|
|
* @return string |
178
|
|
|
*/ |
179
|
6 |
|
protected function detectProjectBugTraqRegEx($project_path, $revision, $project_deleted, $cache_overwrite = false) |
180
|
|
|
{ |
181
|
6 |
|
$ref_paths = $this->getLastChangedRefPaths($project_path, $revision, $project_deleted); |
182
|
|
|
|
183
|
6 |
|
if ( !$ref_paths ) { |
184
|
2 |
|
return ''; |
185
|
|
|
} |
186
|
|
|
|
187
|
4 |
|
foreach ( $ref_paths as $ref_path ) { |
188
|
4 |
|
$logregex = $this->_repositoryConnector |
189
|
4 |
|
->withCacheDuration('1 year') |
190
|
4 |
|
->withCacheOverwrite($cache_overwrite) |
191
|
4 |
|
->getProperty( |
192
|
4 |
|
'bugtraq:logregex', |
193
|
4 |
|
$this->_repositoryUrl . $ref_path . ($project_deleted ? '@' . $revision : '') |
194
|
|
|
); |
195
|
|
|
|
196
|
4 |
|
if ( strlen($logregex) ) { |
197
|
4 |
|
return $logregex; |
198
|
|
|
} |
199
|
|
|
} |
200
|
|
|
|
201
|
|
|
return ''; |
202
|
|
|
} |
203
|
|
|
|
204
|
|
|
/** |
205
|
|
|
* Returns given project refs, where last changed are on top. |
206
|
|
|
* |
207
|
|
|
* @param string $project_path Path. |
208
|
|
|
* @param integer $revision Revision. |
209
|
|
|
* @param boolean $project_deleted Project is deleted. |
210
|
|
|
* |
211
|
|
|
* @return array |
212
|
|
|
*/ |
213
|
6 |
|
protected function getLastChangedRefPaths($project_path, $revision, $project_deleted) |
214
|
|
|
{ |
215
|
6 |
|
$own_nesting_level = substr_count($project_path, '/') - 1; |
216
|
|
|
|
217
|
|
|
$where_clause = array( |
218
|
6 |
|
'Path LIKE :parent_path', |
219
|
|
|
'PathNestingLevel BETWEEN :from_level AND :to_level', |
220
|
|
|
); |
221
|
|
|
|
222
|
6 |
|
if ( $project_deleted ) { |
223
|
|
|
// For deleted project scan paths, that existed at project removal time. |
224
|
2 |
|
$where_clause[] = 'RevisionDeleted > ' . $revision; |
225
|
|
|
} |
226
|
|
|
else { |
227
|
|
|
// For active project scan paths, that are not deleted. |
228
|
4 |
|
$where_clause[] = 'RevisionDeleted IS NULL'; |
229
|
|
|
} |
230
|
|
|
|
231
|
|
|
$sql = 'SELECT Path, RevisionLastSeen |
232
|
|
|
FROM Paths |
233
|
6 |
|
WHERE (' . implode(') AND (', $where_clause) . ')'; |
234
|
6 |
|
$paths = $this->database->fetchPairs($sql, array( |
235
|
6 |
|
'parent_path' => $project_path . '%', |
236
|
6 |
|
'from_level' => $own_nesting_level + 1, |
237
|
6 |
|
'to_level' => $own_nesting_level + 2, |
238
|
|
|
)); |
239
|
|
|
|
240
|
|
|
// No sub-folders. |
241
|
6 |
|
if ( !$paths ) { |
|
|
|
|
242
|
1 |
|
return array(); |
243
|
|
|
} |
244
|
|
|
|
245
|
5 |
|
$filtered_paths = array(); |
246
|
|
|
|
247
|
5 |
|
foreach ( $paths as $path => $last_seen_revision ) { |
248
|
5 |
|
if ( $this->isRef($path) ) { |
249
|
4 |
|
$filtered_paths[$path] = $last_seen_revision; |
250
|
|
|
} |
251
|
|
|
} |
252
|
|
|
|
253
|
|
|
// None of sub-folders matches a ref. |
254
|
5 |
|
if ( !$filtered_paths ) { |
255
|
1 |
|
return array(); |
256
|
|
|
} |
257
|
|
|
|
258
|
4 |
|
arsort($filtered_paths, SORT_NUMERIC); |
259
|
|
|
|
260
|
4 |
|
return array_keys($filtered_paths); |
261
|
|
|
} |
262
|
|
|
|
263
|
|
|
/** |
264
|
|
|
* Detects if given project_path is known project root. |
265
|
|
|
* |
266
|
|
|
* @param string $path Path. |
267
|
|
|
* |
268
|
|
|
* @return boolean |
269
|
|
|
*/ |
270
|
5 |
|
protected function isRef($path) |
271
|
|
|
{ |
272
|
|
|
// Not a folder. |
273
|
5 |
|
if ( substr($path, -1, 1) !== '/' ) { |
274
|
4 |
|
return false; |
275
|
|
|
} |
276
|
|
|
|
277
|
5 |
|
return $this->_repositoryConnector->isRefRoot($path); |
278
|
|
|
} |
279
|
|
|
|
280
|
|
|
/** |
281
|
|
|
* Detects bugs, associated with each commit from a given revision range. |
282
|
|
|
* |
283
|
|
|
* @param integer $from_revision From revision. |
284
|
|
|
* @param integer $to_revision To revision. |
285
|
|
|
* |
286
|
|
|
* @return void |
287
|
|
|
*/ |
288
|
8 |
|
protected function detectBugs($from_revision, $to_revision) |
289
|
|
|
{ |
290
|
8 |
|
$bug_regexp_mapping = $this->getProjectBugRegExps(); |
291
|
|
|
|
292
|
8 |
|
if ( !$bug_regexp_mapping ) { |
293
|
3 |
|
$this->advanceProgressBar(); |
294
|
|
|
|
295
|
3 |
|
return; |
296
|
|
|
} |
297
|
|
|
|
298
|
5 |
|
$range_start = $from_revision; |
299
|
|
|
|
300
|
5 |
|
while ( $range_start <= $to_revision ) { |
301
|
5 |
|
$range_end = min($range_start + 999, $to_revision); |
302
|
|
|
|
303
|
5 |
|
$this->doDetectBugs($range_start, $range_end, $bug_regexp_mapping); |
304
|
5 |
|
$this->advanceProgressBar(); |
305
|
|
|
|
306
|
5 |
|
$range_start = $range_end + 1; |
307
|
|
|
} |
308
|
5 |
|
} |
309
|
|
|
|
310
|
|
|
/** |
311
|
|
|
* Returns "BugRegExp" field associated with every project. |
312
|
|
|
* |
313
|
|
|
* @return array |
314
|
|
|
*/ |
315
|
8 |
|
protected function getProjectBugRegExps() |
316
|
|
|
{ |
317
|
8 |
|
$projects = $this->getProjects("BugRegExp != ''"); |
318
|
|
|
|
319
|
8 |
|
if ( !$projects ) { |
320
|
3 |
|
return array(); |
321
|
|
|
} |
322
|
|
|
|
323
|
5 |
|
$ret = array(); |
324
|
|
|
|
325
|
5 |
|
foreach ( $projects as $project_data ) { |
326
|
5 |
|
$ret[$project_data['Id']] = $project_data['BugRegExp']; |
327
|
|
|
} |
328
|
|
|
|
329
|
5 |
|
return $ret; |
330
|
|
|
} |
331
|
|
|
|
332
|
|
|
/** |
333
|
|
|
* Detects bugs, associated with each commit from a given revision range. |
334
|
|
|
* |
335
|
|
|
* @param integer $from_revision From revision. |
336
|
|
|
* @param integer $to_revision To revision. |
337
|
|
|
* @param array $bug_regexp_mapping Mapping between project and it's "BugRegExp" field. |
338
|
|
|
* |
339
|
|
|
* @return void |
340
|
|
|
*/ |
341
|
5 |
|
protected function doDetectBugs($from_revision, $to_revision, array $bug_regexp_mapping) |
342
|
|
|
{ |
343
|
5 |
|
$commits_by_project = $this->getCommitsGroupedByProject($from_revision, $to_revision); |
344
|
|
|
|
345
|
5 |
|
foreach ( $commits_by_project as $project_id => $project_commits ) { |
346
|
5 |
|
if ( !isset($bug_regexp_mapping[$project_id]) ) { |
347
|
1 |
|
continue; |
348
|
|
|
} |
349
|
|
|
|
350
|
5 |
|
$log_message_parser = $this->_logMessageParserFactory->getLogMessageParser( |
351
|
5 |
|
$bug_regexp_mapping[$project_id] |
352
|
|
|
); |
353
|
|
|
|
354
|
5 |
|
foreach ( $project_commits as $revision => $log_message ) { |
355
|
5 |
|
$bugs = $log_message_parser->parse($log_message); |
356
|
|
|
|
357
|
5 |
|
if ( $bugs ) { |
358
|
4 |
|
$this->repositoryFiller->addBugsToCommit($bugs, $revision); |
359
|
4 |
|
$this->recordStatistic(self::STATISTIC_BUG_ADDED_TO_COMMIT, count($bugs)); |
360
|
|
|
} |
361
|
|
|
} |
362
|
|
|
} |
363
|
5 |
|
} |
364
|
|
|
|
365
|
|
|
/** |
366
|
|
|
* Returns commits grouped by project. |
367
|
|
|
* |
368
|
|
|
* @param integer $from_revision From revision. |
369
|
|
|
* @param integer $to_revision To revision. |
370
|
|
|
* |
371
|
|
|
* @return array |
372
|
|
|
*/ |
373
|
5 |
|
protected function getCommitsGroupedByProject($from_revision, $to_revision) |
374
|
|
|
{ |
375
|
5 |
|
$sql = 'SELECT cp.Revision, c.Message, cp.ProjectId |
376
|
|
|
FROM CommitProjects cp |
377
|
|
|
JOIN Commits c ON c.Revision = cp.Revision |
378
|
|
|
WHERE cp.Revision BETWEEN :from_revision AND :to_revision'; |
379
|
5 |
|
$commits = $this->database->yieldAll($sql, array( |
380
|
5 |
|
'from_revision' => $from_revision, |
381
|
5 |
|
'to_revision' => $to_revision, |
382
|
|
|
)); |
383
|
|
|
|
384
|
5 |
|
$ret = array(); |
385
|
5 |
|
$processed_revisions = array(); |
386
|
|
|
|
387
|
5 |
|
foreach ( $commits as $commit_data ) { |
388
|
5 |
|
$revision = $commit_data['Revision']; |
389
|
|
|
|
390
|
|
|
// Don't process revision more then once (e.g. when commit belongs to different projects). |
391
|
5 |
|
if ( isset($processed_revisions[$revision]) ) { |
392
|
1 |
|
continue; |
393
|
|
|
} |
394
|
|
|
|
395
|
5 |
|
$project_id = $commit_data['ProjectId']; |
396
|
|
|
|
397
|
5 |
|
if ( !isset($ret[$project_id]) ) { |
398
|
5 |
|
$ret[$project_id] = array(); |
399
|
|
|
} |
400
|
|
|
|
401
|
5 |
|
$ret[$project_id][$revision] = $commit_data['Message']; |
402
|
5 |
|
$processed_revisions[$revision] = true; |
403
|
|
|
} |
404
|
|
|
|
405
|
5 |
|
return $ret; |
406
|
|
|
} |
407
|
|
|
|
408
|
|
|
/** |
409
|
|
|
* Find revisions by collected data. |
410
|
|
|
* |
411
|
|
|
* @param array $criteria Criteria. |
412
|
|
|
* @param string $project_path Project path. |
413
|
|
|
* |
414
|
|
|
* @return array |
415
|
|
|
*/ |
416
|
5 |
|
public function find(array $criteria, $project_path) |
417
|
|
|
{ |
418
|
5 |
|
if ( !$criteria ) { |
|
|
|
|
419
|
1 |
|
return array(); |
420
|
|
|
} |
421
|
|
|
|
422
|
4 |
|
$project_id = $this->getProject($project_path); |
423
|
|
|
|
424
|
3 |
|
$sql = 'SELECT DISTINCT cb.Revision |
425
|
|
|
FROM CommitBugs cb |
426
|
|
|
JOIN CommitProjects cp ON cp.Revision = cb.Revision |
427
|
|
|
WHERE cp.ProjectId = :project_id AND cb.Bug IN (:bugs)'; |
428
|
3 |
|
$bug_revisions = $this->database->fetchCol($sql, array('project_id' => $project_id, 'bugs' => $criteria)); |
429
|
|
|
|
430
|
3 |
|
sort($bug_revisions, SORT_NUMERIC); |
431
|
|
|
|
432
|
3 |
|
return $bug_revisions; |
433
|
|
|
} |
434
|
|
|
|
435
|
|
|
/** |
436
|
|
|
* Returns information about revisions. |
437
|
|
|
* |
438
|
|
|
* @param array $revisions Revisions. |
439
|
|
|
* |
440
|
|
|
* @return array |
441
|
|
|
*/ |
442
|
1 |
|
public function getRevisionsData(array $revisions) |
443
|
|
|
{ |
444
|
1 |
|
$results = array(); |
445
|
|
|
|
446
|
1 |
|
$sql = 'SELECT Revision, Bug |
447
|
|
|
FROM CommitBugs |
448
|
|
|
WHERE Revision IN (:revisions)'; |
449
|
1 |
|
$revisions_data = $this->getRawRevisionsData($sql, 'revisions', $revisions); |
450
|
|
|
|
451
|
1 |
|
foreach ( $revisions_data as $revision_data ) { |
452
|
1 |
|
$revision = $revision_data['Revision']; |
453
|
1 |
|
$bug = $revision_data['Bug']; |
454
|
|
|
|
455
|
1 |
|
if ( !isset($results[$revision]) ) { |
456
|
1 |
|
$results[$revision] = array(); |
457
|
|
|
} |
458
|
|
|
|
459
|
1 |
|
$results[$revision][] = $bug; |
460
|
|
|
} |
461
|
|
|
|
462
|
1 |
|
return $this->addMissingResults($revisions, $results); |
463
|
|
|
} |
464
|
|
|
|
465
|
|
|
/** |
466
|
|
|
* Refreshes BugRegExp of a project. |
467
|
|
|
* |
468
|
|
|
* @param string $project_path Project path. |
469
|
|
|
* |
470
|
|
|
* @return void |
471
|
|
|
*/ |
472
|
2 |
|
public function refreshBugRegExp($project_path) |
473
|
|
|
{ |
474
|
2 |
|
$project_id = $this->getProject($project_path); |
475
|
|
|
|
476
|
2 |
|
$sql = 'UPDATE Projects |
477
|
|
|
SET BugRegExp = NULL |
478
|
|
|
WHERE Id = :project_id'; |
479
|
2 |
|
$this->database->perform($sql, array( |
480
|
2 |
|
'project_id' => $project_id, |
481
|
|
|
)); |
482
|
|
|
|
483
|
2 |
|
$this->populateMissingBugRegExp(true); |
484
|
2 |
|
} |
485
|
|
|
|
486
|
|
|
} |
487
|
|
|
|
This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.
Consider making the comparison explicit by using
empty(..)
or! empty(...)
instead.