Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 196
0.00% covered (danger)
0.00%
0 / 26
CRAP
0.00% covered (danger)
0.00%
0 / 1
CodeRepository
0.00% covered (danger)
0.00%
0 / 196
0.00% covered (danger)
0.00%
0 / 26
4032
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 newFromName
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 newFromId
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 newFromRow
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 getRepoList
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 getId
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getPath
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getViewVcBase
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getBugzillaBase
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getBugPath
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getLastStoredRev
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getAuthorList
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
12
 getAuthorCount
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getTagList
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
 getRevision
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 getRevIdString
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getRevIdStringUnique
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 getDiff
0.00% covered (danger)
0.00%
0 / 46
0.00% covered (danger)
0.00%
0 / 1
182
 setDiffCache
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
2
 isValidRev
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 linkUser
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
 unlinkUser
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 authorWikiUser
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
 wikiUserAuthor
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 getDiffErrorMessage
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
72
1<?php
2
3namespace MediaWiki\Extension\CodeReview\Backend;
4
5use Exception;
6use MediaWiki\MediaWikiServices;
7use stdClass;
8use User;
9
10/**
11 * Core class for interacting with a repository of code.
12 */
13class CodeRepository {
14    public const DIFFRESULT_BADREVISION = 0;
15    public const DIFFRESULT_NOTHINGTOCOMPARE = 1;
16    public const DIFFRESULT_TOOMANYPATHS = 2;
17    public const DIFFRESULT_NODATARETURNED = 3;
18    public const DIFFRESULT_NOTINCACHE = 4;
19
20    /**
21     * Local cache of Wiki user -> SVN user mappings
22     * @var array
23     */
24    private static $userLinks = [];
25
26    /**
27     * Sort of the same, but looking it up for the other direction
28     * @var array
29     */
30    private static $authorLinks = [];
31
32    private $id;
33
34    private $name;
35
36    private $path;
37
38    private $viewVc;
39
40    private $bugzilla;
41
42    /**
43     * Constructor, can't use it. Call one of the static newFrom* methods
44     * @param int $id Database ID for the repo
45     * @param string $name User-defined name for the repository
46     * @param string $path Path to SVN
47     * @param string $viewvc Base path to ViewVC URLs
48     * @param string $bugzilla Base path to Bugzilla
49     */
50    public function __construct( $id, $name, $path, $viewvc, $bugzilla ) {
51        $this->id = $id;
52        $this->name = $name;
53        $this->path = $path;
54        $this->viewVc = $viewvc;
55        $this->bugzilla = $bugzilla;
56    }
57
58    /**
59     * @param string $name
60     * @return CodeRepository|null
61     */
62    public static function newFromName( $name ) {
63        $dbw = wfGetDB( DB_REPLICA );
64        $row = $dbw->selectRow(
65            'code_repo',
66            [
67                'repo_id',
68                'repo_name',
69                'repo_path',
70                'repo_viewvc',
71                'repo_bugzilla'
72            ],
73            [ 'repo_name' => $name ],
74            __METHOD__ );
75
76        if ( $row ) {
77            return self::newFromRow( $row );
78        } else {
79            return null;
80        }
81    }
82
83    /**
84     * @param int $id
85     * @return CodeRepository|null
86     */
87    public static function newFromId( $id ) {
88        $dbw = wfGetDB( DB_REPLICA );
89        $row = $dbw->selectRow(
90            'code_repo',
91            [
92                'repo_id',
93                'repo_name',
94                'repo_path',
95                'repo_viewvc',
96                'repo_bugzilla' ],
97            [ 'repo_id' => intval( $id ) ],
98            __METHOD__ );
99
100        if ( $row ) {
101            return self::newFromRow( $row );
102        } else {
103            return null;
104        }
105    }
106
107    /**
108     * @param stdClass $row
109     * @return CodeRepository
110     */
111    public static function newFromRow( $row ) {
112        return new CodeRepository(
113            intval( $row->repo_id ),
114            $row->repo_name,
115            $row->repo_path,
116            $row->repo_viewvc,
117            $row->repo_bugzilla
118        );
119    }
120
121    /**
122     * @return array
123     */
124    public static function getRepoList() {
125        $dbr = wfGetDB( DB_REPLICA );
126        $options = [ 'ORDER BY' => 'repo_name' ];
127        $res = $dbr->select( 'code_repo', '*', [], __METHOD__, $options );
128        $repos = [];
129        foreach ( $res as $row ) {
130            $repos[] = self::newFromRow( $row );
131        }
132        return $repos;
133    }
134
135    /**
136     * @return int
137     */
138    public function getId() {
139        return intval( $this->id );
140    }
141
142    /**
143     * @return string
144     */
145    public function getName() {
146        return $this->name;
147    }
148
149    /**
150     * @return string
151     */
152    public function getPath() {
153        return $this->path;
154    }
155
156    /**
157     * @return string
158     */
159    public function getViewVcBase() {
160        return $this->viewVc;
161    }
162
163    /**
164     * @return string
165     */
166    public function getBugzillaBase() {
167        return $this->bugzilla;
168    }
169
170    /**
171     * Return a bug URL or false
172     *
173     * @param int|string $bugId
174     * @return string|bool
175     */
176    public function getBugPath( $bugId ) {
177        if ( $this->bugzilla ) {
178            return str_replace( '$1',
179                urlencode( $bugId ), $this->bugzilla );
180        }
181        return false;
182    }
183
184    /**
185     * @return int
186     */
187    public function getLastStoredRev() {
188        $dbr = wfGetDB( DB_REPLICA );
189        $row = $dbr->selectField(
190            'code_rev',
191            'MAX(cr_id)',
192            [ 'cr_repo_id' => $this->getId() ],
193            __METHOD__
194        );
195        return intval( $row );
196    }
197
198    /**
199     * @return array
200     */
201    public function getAuthorList() {
202        $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
203        $method = __METHOD__;
204
205        return $cache->getWithSetCallback(
206            $cache->makeKey( 'codereview-authors', $this->getId() ),
207            $cache::TTL_DAY,
208            function () use ( $method ) {
209                $dbr = wfGetDB( DB_REPLICA );
210                $res = $dbr->select(
211                    'code_rev',
212                    [ 'cr_author', 'MAX(cr_timestamp) AS time' ],
213                    [ 'cr_repo_id' => $this->getId() ],
214                    $method,
215                    [
216                        'GROUP BY' => 'cr_author',
217                        'ORDER BY' => 'cr_author',
218                        'LIMIT' => 500
219                    ]
220                );
221
222                $authors = [];
223                foreach ( $res as $row ) {
224                    if ( $row->cr_author !== null ) {
225                        $authors[] = [
226                            'author' => $row->cr_author,
227                            'lastcommit' => $row->time
228                        ];
229                    }
230                }
231
232                return $authors;
233            }
234        );
235    }
236
237    /**
238     * @return int
239     */
240    public function getAuthorCount() {
241        return count( $this->getAuthorList() );
242    }
243
244    /**
245     * Get a list of all tags in use in the repository
246     * @param bool $recache whether to get clean data
247     * @return array
248     */
249    public function getTagList( $recache = false ) {
250        $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
251        $method = __METHOD__;
252
253        return $cache->getWithSetCallback(
254            $cache->makeKey( 'codereview-tags', $this->getId() ),
255            $cache::TTL_DAY,
256            function () use ( $method ) {
257                $dbr = wfGetDB( DB_REPLICA );
258                $res = $dbr->select(
259                    'code_tags',
260                    [ 'ct_tag', 'COUNT(*) AS revs' ],
261                    [ 'ct_repo_id' => $this->getId() ],
262                    $method,
263                    [
264                        'GROUP BY' => 'ct_tag',
265                        'ORDER BY' => 'revs DESC',
266                        'LIMIT' => 500
267                    ]
268                );
269
270                $tags = [];
271                foreach ( $res as $row ) {
272                    $tags[$row->ct_tag] = $row->revs;
273                }
274
275                return $tags;
276            },
277            [ 'minAsOf' => $recache ? INF : $cache::MIN_TIMESTAMP_NONE ]
278        );
279    }
280
281    /**
282     * Load a particular revision out of the DB.
283     *
284     * @param int|string $id
285     * @return CodeRevision|null
286     * @throws Exception
287     */
288    public function getRevision( $id ) {
289        if ( !$this->isValidRev( $id ) ) {
290            return null;
291        }
292        $dbr = wfGetDB( DB_REPLICA );
293        $row = $dbr->selectRow(
294            'code_rev',
295            '*',
296            [
297                'cr_id' => $id,
298                'cr_repo_id' => $this->getId(),
299            ],
300            __METHOD__
301        );
302        if ( !$row ) {
303            return null;
304        }
305        return CodeRevision::newFromRow( $this, $row );
306    }
307
308    /**
309     * Returns the supplied revision ID as a string ready for output, including the
310     * appropriate (localisable) prefix (e.g. "r123" instead of 123).
311     *
312     * @param string $id
313     * @return string
314     */
315    public function getRevIdString( $id ) {
316        return wfMessage( 'code-rev-id', $id )->text();
317    }
318
319    /**
320     * Like getRevIdString(), but if more than one repository is defined
321     * on the wiki then it includes the repo name as a prefix to the revision ID
322     * (separated with a period).
323     * This ensures you get a unique reference, as the revision ID alone can be
324     * confusing (e.g. in emails, page titles etc.). If only one repository is
325     * defined then this returns the same as getRevIdString() as there
326     * is no ambiguity.
327     *
328     * @param string $id
329     * @return string
330     */
331    public function getRevIdStringUnique( $id ) {
332        $id = wfMessage( 'code-rev-id', $id )->text();
333
334        // If there is more than one repo, use the repo name as well.
335        $repos = self::getRepoList();
336        if ( count( $repos ) > 1 ) {
337            $id = $this->getName() . '.' . $id;
338        }
339
340        return $id;
341    }
342
343    /**
344     * @param int $rev Revision ID
345     * @param string $useCache 'skipcache' to avoid caching
346     *                   'cached' to *only* fetch if cached
347     * @return string|int The diff text on success, a DIFFRESULT_* constant on failure.
348     */
349    public function getDiff( $rev, $useCache = '' ) {
350        global $wgCodeReviewMaxDiffPaths;
351
352        $data = null;
353
354        $rev1 = $rev - 1;
355        $rev2 = $rev;
356
357        // Check that a valid revision was specified.
358        $revision = $this->getRevision( $rev );
359        if ( $revision == null ) {
360            $data = self::DIFFRESULT_BADREVISION;
361        } else {
362            // Check that there is at least one, and at most $wgCodeReviewMaxDiffPaths
363            // paths changed in this revision.
364            $paths = $revision->getModifiedPaths();
365            if ( !$paths->numRows() ) {
366                $data = self::DIFFRESULT_NOTHINGTOCOMPARE;
367            } elseif (
368                $wgCodeReviewMaxDiffPaths > 0 &&
369                $paths->numRows() > $wgCodeReviewMaxDiffPaths
370            ) {
371                $data = self::DIFFRESULT_TOOMANYPATHS;
372            }
373        }
374
375        // If an error has occurred, return it.
376        if ( $data !== null ) {
377            return $data;
378        }
379
380        $services = MediaWikiServices::getInstance();
381        $cache = $services->getMainWANObjectCache();
382        $blobStore = $services->getBlobStore();
383        $method = __METHOD__;
384
385        return $cache->getWithSetCallback(
386            // Set up the cache key, which will be used both to check if already in the
387            // cache, and to write the final result to the cache.
388            $cache->makeKey( 'svn-diff', md5( $this->path ), $rev1, $rev2 ),
389            $cache::TTL_DAY,
390            function ( $oldValue, &$ttl ) use (
391                $rev, $rev1, $rev2, $useCache, $cache, $method, $blobStore
392            ) {
393                // Check permanent DB storage cache
394                if ( $useCache !== 'skipcache' ) {
395                    $dbr = wfGetDB( DB_REPLICA );
396                    $row = $dbr->selectRow(
397                        'code_rev',
398                        [ 'cr_diff', 'cr_flags' ],
399                        [ 'cr_repo_id' => $this->id, 'cr_id' => $rev, 'cr_diff IS NOT NULL' ],
400                        $method
401                    );
402
403                    if ( $row ) {
404                        $flags = explode( ',', $row->cr_flags );
405                        // If the text was fetched without an error, convert it
406                        if ( in_array( 'gzip', $flags ) ) {
407                            # Deal with optional compression of archived pages.
408                            # This can be done periodically via maintenance/compressOld.php, and
409                            # as pages are saved if $wgCompressRevisions is set.
410                            $data = gzinflate( $row->cr_diff );
411                        } else {
412                            $data = $row->cr_diff;
413                        }
414
415                        if ( is_string( $data ) ) {
416                            return $data;
417                        }
418                    }
419                }
420
421                // If the data was not already in the cache nor in the DB, retrieve it from SVN
422                if ( $useCache === 'cached' ) {
423                    // If the calling code is forcing a cache check, report that it wasn't in cache
424                    $ttl = $cache::TTL_UNCACHEABLE;
425
426                    return self::DIFFRESULT_NOTINCACHE;
427                }
428
429                // Otherwise, retrieve the diff using SubversionAdaptor
430                $svn = SubversionAdaptor::newFromRepo( $this->path );
431                $data = $svn->getDiff( '', $rev1, $rev2 );
432
433                // If $data is blank, report the error that no data was returned.
434                // TODO: Currently we can't tell the difference between an SVN/connection
435                // failure and an empty diff. See if we can remedy this!
436                if ( $data == '' ) {
437                    $ttl = $cache::TTL_UNCACHEABLE;
438
439                    return self::DIFFRESULT_NODATARETURNED;
440                }
441
442                // Backfill permanent DB storage cache
443                $storedData = $data;
444                $flags = $blobStore->compressData( $storedData );
445
446                $dbw = wfGetDB( DB_PRIMARY );
447                $dbw->update(
448                    'code_rev',
449                    [ 'cr_diff' => $storedData, 'cr_flags' => $flags ],
450                    [ 'cr_repo_id' => $this->id, 'cr_id' => $rev ],
451                    $method
452                );
453
454                return $data;
455            },
456            // If not set to explicitly skip the cache, get the current diff from memcached
457            [ 'minTime' => ( $useCache === 'skipcache' ) ? INF : $cache::MIN_TIMESTAMP_NONE ]
458        );
459    }
460
461    /**
462     * Set diff cache (for import operations)
463     * @param CodeRevision $codeRev
464     */
465    public function setDiffCache( CodeRevision $codeRev ) {
466        $rev1 = $codeRev->getId() - 1;
467        $rev2 = $codeRev->getId();
468
469        $services = MediaWikiServices::getInstance();
470        $cache = $services->getMainWANObjectCache();
471        $data = $cache->getWithSetCallback(
472            $cache->makeKey( 'svn-diff', md5( $this->path ), $rev1, $rev2 ),
473            3 * $cache::TTL_DAY,
474            function () use ( $rev1, $rev2 ) {
475                $svn = SubversionAdaptor::newFromRepo( $this->path );
476
477                return $svn->getDiff( '', $rev1, $rev2 );
478            }
479        );
480
481        // Permanent DB storage
482        $storedData = $data;
483        $flags = $services->getBlobStore()->compressData( $storedData );
484        $dbw = wfGetDB( DB_PRIMARY );
485        $dbw->update(
486            'code_rev',
487            [ 'cr_diff' => $storedData, 'cr_flags' => $flags ],
488            [ 'cr_repo_id' => $this->id, 'cr_id' => $codeRev->getId() ],
489            __METHOD__
490        );
491    }
492
493    /**
494     * Is the requested revid a valid revision to show?
495     * @return bool
496     * @param int $rev Rev ID to check
497     */
498    public function isValidRev( $rev ) {
499        $rev = intval( $rev );
500        return ( $rev > 0 && $rev <= $this->getLastStoredRev() );
501    }
502
503    /**
504     * Link the $author to the wikiuser $user
505     * @param string $author
506     * @param User $user
507     * @return bool Success
508     */
509    public function linkUser( $author, User $user ) {
510        $userId = $user->getId();
511        // We must link to an existing user
512        if ( !$userId ) {
513            return false;
514        }
515        $dbw = wfGetDB( DB_PRIMARY );
516        // Insert in the auther -> user link row.
517        // Skip existing rows.
518        $dbw->insert(
519            'code_authors',
520            [
521                'ca_repo_id'   => $this->getId(),
522                'ca_author'    => $author,
523                'ca_user'      => $userId,
524                'ca_user_text' => $user->getName()
525            ],
526            __METHOD__,
527            [ 'IGNORE' ]
528        );
529        // If the last query already found a row, then update it.
530        if ( !$dbw->affectedRows() ) {
531            $dbw->update(
532                'code_authors',
533                [
534                    'ca_user'      => $userId,
535                    'ca_user_text' => $user->getName()
536                ],
537                [
538                    'ca_repo_id'  => $this->getId(),
539                    'ca_author'   => $author,
540                ],
541                __METHOD__
542            );
543        }
544        self::$userLinks[$author] = $user;
545        return ( $dbw->affectedRows() > 0 );
546    }
547
548    /**
549     * Remove local user links for $author
550     * @param string $author
551     * @return bool success
552     */
553    public function unlinkUser( $author ) {
554        $dbw = wfGetDB( DB_PRIMARY );
555        $dbw->delete(
556            'code_authors',
557            [
558                'ca_repo_id' => $this->getId(),
559                'ca_author'  => $author,
560            ],
561            __METHOD__
562        );
563        self::$userLinks[$author] = false;
564        return ( $dbw->affectedRows() > 0 );
565    }
566
567    /**
568     * returns a User object if $author has a wikiuser associated,
569     * or false
570     *
571     * @param string $author
572     * @return User|bool
573     */
574    public function authorWikiUser( $author ) {
575        if ( isset( self::$userLinks[$author] ) ) {
576            return self::$userLinks[$author];
577        }
578
579        $dbr = wfGetDB( DB_REPLICA );
580        $wikiUser = $dbr->selectField(
581            'code_authors',
582            'ca_user_text',
583            [
584                'ca_repo_id' => $this->getId(),
585                'ca_author'  => $author,
586            ],
587            __METHOD__
588        );
589        $user = null;
590        if ( $wikiUser !== false ) {
591            $user = User::newFromName( $wikiUser );
592        }
593        if ( $user instanceof User ) {
594            self::$userLinks[$author] = $user;
595        } else {
596            self::$userLinks[$author] = false;
597        }
598        return self::$userLinks[$author];
599    }
600
601    /**
602     * returns an author name if $name wikiuser has an author associated,
603     * or false
604     *
605     * @param string $name
606     * @return string|bool
607     */
608    public function wikiUserAuthor( $name ) {
609        if ( isset( self::$authorLinks[$name] ) ) {
610            return self::$authorLinks[$name];
611        }
612
613        $dbr = wfGetDB( DB_REPLICA );
614        self::$authorLinks[$name] = $dbr->selectField(
615            'code_authors',
616            'ca_author',
617            [
618                'ca_repo_id'   => $this->getId(),
619                'ca_user_text' => $name,
620            ],
621            __METHOD__
622        );
623        return self::$authorLinks[$name];
624    }
625
626    /**
627     * @param int|string $diff Error code (int) or diff text (string), as returned from getDiff()
628     * @return string (error message, or empty string if valid diff)
629     */
630    public static function getDiffErrorMessage( $diff ) {
631        global $wgCodeReviewMaxDiffPaths;
632
633        if ( is_int( $diff ) ) {
634            switch ( $diff ) {
635                case self::DIFFRESULT_BADREVISION:
636                    return 'Bad revision';
637                case self::DIFFRESULT_NOTHINGTOCOMPARE:
638                    return 'Nothing to compare';
639                case self::DIFFRESULT_TOOMANYPATHS:
640                    return 'Too many paths ($wgCodeReviewMaxDiffPaths = '
641                            . $wgCodeReviewMaxDiffPaths . ')';
642                case self::DIFFRESULT_NODATARETURNED:
643                    return 'No data returned - no diff data, or connection lost';
644                case self::DIFFRESULT_NOTINCACHE:
645                    return 'Not in cache';
646                default:
647                    return 'Unknown reason!';
648            }
649        }
650
651        // TODO: Should this return "", $diff or a message string, e.g. "OK"?
652        return '';
653    }
654}