├── .dockerignore ├── assets ├── img │ ├── favicon.png │ └── logo.svg └── styles │ └── main.css ├── .github ├── dependabot.yml └── workflows │ └── main.yml ├── .gitignore ├── docker └── php │ └── vhost.conf ├── config.json.custom-example ├── src ├── Model │ ├── GithubRepository.php │ ├── GithubReview.php │ └── Database │ │ └── GithubPullRequest.php ├── Config │ ├── Custom.php │ └── OAuthApp.php ├── DatabaseProvider.php ├── DiffLineFinder.php ├── Auth.php ├── Api.php ├── Config.php ├── PsalmPlugin │ └── GitApiReturnTypeProvider.php ├── PsalmData.php ├── GithubData.php ├── PhpunitData.php ├── Sender.php └── GithubApi.php ├── phpunit.xml ├── config.json.app-example ├── psalm.xml ├── views ├── hooks │ ├── psalm.php │ ├── phpunit.php │ └── github.php ├── auth │ ├── github.php │ ├── github_configure.php │ └── github_redirect.php ├── coverage_data.php ├── level_data.php ├── psalm_open_issues.php ├── index.php ├── shields │ ├── coverage.php │ └── level.php └── history.php ├── Dockerfile ├── docker-compose.yml ├── README.md ├── LICENSE ├── Makefile ├── composer.json ├── .htaccess ├── phpcs.xml ├── .appveyor.yml ├── init.sql └── tests ├── GithubDiffLineTest.php ├── Extension └── FailureTracker.php └── test.diff /.dockerignore: -------------------------------------------------------------------------------- 1 | Dockerfile 2 | tests -------------------------------------------------------------------------------- /assets/img/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/psalm/shepherd/HEAD/assets/img/favicon.png -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: / 5 | schedule: 6 | interval: monthly 7 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | jobs: 5 | psalm: 6 | name: Psalm 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@master 10 | - name: Psalm 11 | uses: docker://vimeo/psalm-github-actions 12 | with: 13 | args: --shepherd 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | composer.phar 2 | /vendor/ 3 | composer.lock 4 | .DS_Store 5 | database/github_commits/*.json 6 | database/github_master_data/*.json 7 | database/github_pr_data/*.json 8 | database/pr_comments/*.json 9 | database/pr_reviews/*.json 10 | database/psalm_data/*.json 11 | database/psalm_master_data/*.json 12 | config.json -------------------------------------------------------------------------------- /docker/php/vhost.conf: -------------------------------------------------------------------------------- 1 | 2 | DocumentRoot /var/www/html 3 | 4 | 5 | AllowOverride all 6 | Require all granted 7 | 8 | 9 | ErrorLog ${APACHE_LOG_DIR}/error.log 10 | CustomLog ${APACHE_LOG_DIR}/access.log combined 11 | -------------------------------------------------------------------------------- /config.json.custom-example: -------------------------------------------------------------------------------- 1 | { 2 | "custom": { 3 | "personal_token": "HARDCODED_API_TOKEN", 4 | "webhook_secret": "OPTIONAL_SECRET" 5 | }, 6 | "gh_enterprise_url": "OPTIONAL_CUSTOM_GITHUB_URL", 7 | "mysql": { 8 | "dsn": "mysql:dbname=shepherd_web;host=db", 9 | "user": "shepherd_user", 10 | "password": "shepherd_mysql_development_password" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/Model/GithubRepository.php: -------------------------------------------------------------------------------- 1 | owner_name = $owner_name; 16 | $this->repo_name = $repo_name; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | ./tests 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /config.json.app-example: -------------------------------------------------------------------------------- 1 | { 2 | "oauth_app": { 3 | "client_id": "CLIENT_ID", 4 | "client_secret": "CLIENT_SECRET", 5 | "public_access_oauth_token": "OPTIONAL_PUBLIC_ACCESS_TOKEN" 6 | }, 7 | "gh_enterprise_url": "OPTIONAL_CUSTOM_GITHUB_URL", 8 | "mysql": { 9 | "dsn": "mysql:dbname=shepherd_web;host=db", 10 | "user": "shepherd_user", 11 | "password": "shepherd_mysql_development_password" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /psalm.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /views/hooks/psalm.php: -------------------------------------------------------------------------------- 1 | */ 16 | public $file_comments; 17 | 18 | /** 19 | * @param array $file_comments 20 | */ 21 | public function __construct(string $message, bool $checks_passed, array $file_comments = []) 22 | { 23 | $this->message = $message; 24 | $this->checks_passed = $checks_passed; 25 | $this->file_comments = $file_comments; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Config/Custom.php: -------------------------------------------------------------------------------- 1 | personal_token = $personal_token; 23 | $this->webhook_secret = $webhook_secret; 24 | $this->gh_enterprise_url = $gh_enterprise_url; 25 | $this->mysql = $mysql; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /views/auth/github.php: -------------------------------------------------------------------------------- 1 | $config->client_id, 13 | 'redirect_uri' => 'https://' . $_SERVER['HTTP_HOST'] . '/auth/github/redirect', 14 | 'allow_signup' => false, 15 | 'scope' => 'repo write:repo_hook read:org', 16 | 'state' => hash_hmac('sha256', $_SERVER['REMOTE_ADDR'], $config->client_secret) 17 | ]; 18 | 19 | $github_url = $config->gh_enterprise_url ?: 'https://github.com'; 20 | 21 | header('Location: ' . $github_url . '/login/oauth/authorize?' . http_build_query($params)); 22 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.4' 2 | services: 3 | shepherd-php: 4 | container_name: shepherd-php 5 | build: 6 | context: . 7 | dockerfile: Dockerfile 8 | ports: 9 | - "8080:80" 10 | volumes: 11 | - type: bind 12 | source: "${PWD}/database" 13 | target: "/var/www/html/database" 14 | 15 | db: 16 | container_name: shepherd-sql 17 | image: mysql/mysql-server:5.6 18 | volumes: 19 | - type: "bind" 20 | source: "${PWD}/init.sql" 21 | target: "/docker-entrypoint-initdb.d/setup-docker-mysql.sql" 22 | ports: 23 | - "6513:3306" 24 | environment: 25 | MYSQL_ROOT_PASSWORD: docker_root_pass 26 | MYSQL_DATABASE: test_db 27 | MYSQL_USER: devuser 28 | MYSQL_PASSWORD: devpass 29 | networks: 30 | - backend -------------------------------------------------------------------------------- /views/auth/github_configure.php: -------------------------------------------------------------------------------- 1 | gh_enterprise_url); 19 | $client->authenticate($github_token, null, \Github\Client::AUTH_ACCESS_TOKEN); 20 | 21 | $repos = $client 22 | ->api('me') 23 | ->repositories( 24 | 'all', 25 | 'full_name', 26 | 'asc', 27 | 'public' 28 | ); 29 | 30 | /** @psalm-suppress ForbiddenCode */ 31 | var_dump($repos); 32 | -------------------------------------------------------------------------------- /views/auth/github_redirect.php: -------------------------------------------------------------------------------- 1 | client_secret); 12 | 13 | $state = $_GET['state'] ?? null; 14 | $code = $_GET['code'] ?? null; 15 | 16 | if (!$state || $state !== $expected_state) { 17 | throw new UnexpectedValueException('States should match'); 18 | } 19 | 20 | if (!$code) { 21 | throw new UnexpectedValueException('No code sent'); 22 | } 23 | 24 | $github_token = Psalm\Shepherd\Auth::fetchTokenFromGithub($code, $state, $config); 25 | 26 | setcookie('github_token', $github_token); 27 | 28 | header('Location: https://' . $_SERVER['HTTP_HOST'] . '/auth/github/configure'); 29 | -------------------------------------------------------------------------------- /src/Config/OAuthApp.php: -------------------------------------------------------------------------------- 1 | client_id = $client_id; 27 | $this->client_secret = $client_secret; 28 | $this->gh_enterprise_url = $gh_enterprise_url; 29 | $this->public_access_oauth_token = $public_access_oauth_token; 30 | $this->mysql = $mysql; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/DatabaseProvider.php: -------------------------------------------------------------------------------- 1 | mysql; 20 | 21 | try { 22 | $pdo = new PDO($db_config['dsn'], $db_config['user'], $db_config['password']); 23 | } catch (PDOException $e) { 24 | die('Connection to database failed - ' . $e->getMessage()); 25 | } 26 | 27 | $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); 28 | $pdo->setAttribute(PDO::ATTR_CASE, PDO::CASE_LOWER); 29 | $pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, false); 30 | 31 | self::$connection = $pdo; 32 | 33 | return $pdo; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Psalm Shepherd server 2 | 3 | ![Psalm coverage](https://shepherd.dev/github/psalm/shepherd/coverage.svg) 4 | 5 | ## Tracking type coverage in public projects 6 | 7 | Many public GitHub projects (Psalm included) display a small badge showing how much of the project’s code is covered by PHPUnit tests, because test coverage is a useful metric when trying to get an idea of a project’s code quality. 8 | 9 | Hopefully, if you’ve got this far, you think type coverage is also important. To that end, I’ve created a service that allows you to display your project’s type coverage anywhere you want. 10 | 11 | Psalm, PHPUnit and many other projects now display a type coverage badge in their READMEs. 12 | 13 | You can generate your own by adding `--shepherd` to your CI Psalm command. Your badge will then be available at 14 | 15 | `https://shepherd.dev/github/{username}/{repo}/coverage.svg` 16 | This service is the beginning of an ongoing effort for Psalm to support open-source PHP projects hosted on GitHub. 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Psalm 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | help: ## Prints this help 2 | @grep -E '^([a-zA-Z0-9_-]|\%|\/)+:.*?## .*$$' Makefile | sort | awk 'BEGIN {FS = ":.*?## "}; {sub(/\%/, "", $$1)}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' 3 | 4 | default: 5 | composer install 6 | 7 | distclean: ## Distclean 8 | rm -rf vendor/ 9 | make dev-stop 10 | make dev-distclean 11 | 12 | test: ## Run PHPUnit tests 13 | ./vendor/bin/phpunit 14 | 15 | psalm: ## Run Psalm validation 16 | ./vendor/bin/psalm 17 | 18 | test: psalm phpunit 19 | 20 | ## 21 | ## Local development 22 | ## 23 | dev-install: default dev ## Create a local development environment 24 | 25 | dev-init: 26 | docker-compose build 27 | 28 | dev: dev-init ## Start up your development environment (will create a new one if none present) 29 | docker-compose up -d 30 | 31 | dev-stop: ## End the current development environment 32 | docker-compose down 33 | 34 | dev-distclean: ## Wipe the current development environment 35 | docker-compose down --rmi all 36 | 37 | dev-ssh: ## SSH into your current development environment 38 | docker exec -it shepherd-php /bin/bash 39 | 40 | deploy: 41 | docker build . -t $(name) 42 | docker push $(name) -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "psalm/shepherd", 3 | "type": "project", 4 | "description": "What runs on shepherd.dev", 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Matt Brown", 9 | "email": "github@muglug.com" 10 | } 11 | ], 12 | "require": { 13 | "php": "^7.4|^8.0", 14 | "sebastian/diff": "^4.0", 15 | "vimeo/psalm": "dev-master", 16 | "knplabs/github-api": "^3.0", 17 | "guzzlehttp/guzzle": "^7.0.1", 18 | "http-interop/http-factory-guzzle": "^1.0" 19 | }, 20 | "require-dev": { 21 | "phpunit/phpunit": "^9.0", 22 | "squizlabs/php_codesniffer": "^3.6", 23 | "slevomat/coding-standard": "^7.0" 24 | }, 25 | "autoload": { 26 | "psr-4": { 27 | "Psalm\\Shepherd\\": "src" 28 | } 29 | }, 30 | "autoload-dev": { 31 | "psr-4": { 32 | "Psalm\\Shepherd\\Test\\": "tests" 33 | } 34 | }, 35 | "config": { 36 | "allow-plugins": { 37 | "dealerdirect/phpcodesniffer-composer-installer": true, 38 | "composer/package-versions-deprecated": true 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /views/coverage_data.php: -------------------------------------------------------------------------------- 1 | 1, 21 | 'label' => 'type-coverage', 22 | ]; 23 | 24 | if (!$pct) { 25 | $data += [ 26 | 'message' => 'unknown', 27 | 'color' => '#aaa' 28 | ]; 29 | 30 | echo json_encode($data); 31 | exit; 32 | } 33 | 34 | if ($pct > 95) { 35 | $color = '#4c1'; 36 | } elseif ($pct > 90) { 37 | $color = '#97ca00'; 38 | } elseif ($pct > 85) { 39 | $color = '#aeaf12'; 40 | } elseif ($pct > 80) { 41 | $color = '#dfb317'; 42 | } elseif ($pct > 75) { 43 | $color = '#fe7d37'; 44 | } else { 45 | $color = '#e05d44'; 46 | } 47 | 48 | $pct = $pct . '%'; 49 | 50 | $data += [ 51 | 'message' => $pct, 52 | 'color' => $color, 53 | ]; 54 | 55 | echo json_encode($data); 56 | -------------------------------------------------------------------------------- /views/level_data.php: -------------------------------------------------------------------------------- 1 | 1, 21 | 'label' => 'psalm-level', 22 | ]; 23 | 24 | if (!$level) { 25 | $data += [ 26 | 'message' => 'unknown', 27 | 'color' => '#aaa' 28 | ]; 29 | 30 | echo json_encode($data); 31 | exit; 32 | } 33 | 34 | if ($level === 1) { 35 | $color = '#4c1'; 36 | } elseif ($level === 2 || $level === 3) { 37 | $color = '#97ca00'; 38 | } elseif ($level === 4) { 39 | $color = '#aeaf12'; 40 | } elseif ($level === 5) { 41 | $color = '#dfb317'; 42 | } elseif ($level === 6) { 43 | $color = '#fe7d37'; 44 | } else { 45 | $color = '#e05d44'; 46 | } 47 | 48 | $data += [ 49 | 'message' => (string)$level, 50 | 'color' => $color, 51 | ]; 52 | 53 | echo json_encode($data); 54 | -------------------------------------------------------------------------------- /.htaccess: -------------------------------------------------------------------------------- 1 | Options -Indexes 2 | RewriteEngine on 3 | RewriteBase / 4 | 5 | # 404s 6 | RewriteRule ^database/?.*$ - [R=404,NC,L] 7 | RewriteRule ^vendor/?.*$ - [R=404,NC,L] 8 | RewriteRule ^src/?.*$ - [R=404,NC,L] 9 | RewriteRule ^config.*$ - [R=404,NC,L] 10 | 11 | RewriteRule ^hooks/github$ views/hooks/github.php [L,QSA] 12 | RewriteRule ^hooks/psalm$ views/hooks/psalm.php [L,QSA] 13 | RewriteRule ^hooks/phpunit$ views/hooks/phpunit.php [L,QSA] 14 | 15 | RewriteRule ^auth/github$ views/auth/github.php [L,QSA] 16 | RewriteRule ^auth/github/redirect$ views/auth/github_redirect.php [L,QSA] 17 | RewriteRule ^auth/github/configure$ views/auth/github_configure.php [L,QSA] 18 | 19 | RewriteRule ^reprocess/(.*)$ views/reprocess.php?sha=$1 [L,QSA] 20 | 21 | RewriteRule ^github/([-\d\w._]+\/[-\d\w._]+)/coverage.svg$ views/shields/coverage.php?$1 [L,QSA] 22 | RewriteRule ^github/([-\d\w._]+\/[-\d\w._]+)/coverage$ views/coverage_data.php?$1 [L,QSA] 23 | RewriteRule ^github/([-\d\w._]+\/[-\d\w._]+)/level.svg$ views/shields/level.php?$1 [L,QSA] 24 | RewriteRule ^github/([-\d\w._]+\/[-\d\w._]+)/level$ views/level_data.php?$1 [L,QSA] 25 | RewriteRule ^github/([-\d\w._]+\/[-\d\w._]+)$ views/history.php?$1 [L,QSA] 26 | RewriteRule ^psalm_open_issues$ views/psalm_open_issues.php [L,QSA] 27 | 28 | # legacy 29 | RewriteRule ^telemetry$ hooks/psalm [L,QSA] 30 | 31 | RewriteRule ^$ views/index.php [L,QSA] 32 | -------------------------------------------------------------------------------- /phpcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | * 19 | 20 | 21 | 22 | views 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | src/ 31 | tests/ 32 | views 33 | 34 | -------------------------------------------------------------------------------- /src/Model/Database/GithubPullRequest.php: -------------------------------------------------------------------------------- 1 | repository = $repository; 31 | $this->number = $number; 32 | $this->head_commit = $head_commit; 33 | $this->branch = $branch; 34 | $this->url = $url; 35 | } 36 | 37 | /** 38 | * @param array{ 39 | * owner_name: string, 40 | * repo_name: string, 41 | * number: int, 42 | * git_commit: string, 43 | * branch: string, 44 | * url: string 45 | * } $database_data 46 | */ 47 | public static function fromDatabaseData(array $database_data) : self 48 | { 49 | return new self( 50 | new GithubRepository( 51 | $database_data['owner_name'], 52 | $database_data['repo_name'] 53 | ), 54 | $database_data['number'], 55 | $database_data['git_commit'], 56 | $database_data['branch'], 57 | $database_data['url'] 58 | ); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /views/psalm_open_issues.php: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | Shepherd 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 20 | 21 |
22 |
23 | $changes) : ?> 24 |

Link to issue

25 | 26 | [$current_result, $original_result]) : ?> 27 |

Psalm link

28 |

Before:

29 |
30 |

Current:

31 |
32 | 33 | 34 | 35 |

36 | Next 37 |

38 |
39 |
40 | 41 | -------------------------------------------------------------------------------- /src/DiffLineFinder.php: -------------------------------------------------------------------------------- 1 | parse($diff_string); 16 | 17 | foreach ($diffs as $diff) { 18 | if ($diff->getTo() === 'b/' . $file_name) { 19 | $diff_file_offset = 0; 20 | 21 | foreach ($diff->getChunks() as $chunk) { 22 | $chunk_end = $chunk->getEnd(); 23 | $chunk_end_range = $chunk->getEndRange(); 24 | 25 | if ($input_line >= $chunk_end 26 | && $input_line < $chunk_end + $chunk_end_range 27 | ) { 28 | $line_offset = 0; 29 | foreach ($chunk->getLines() as $chunk_line) { 30 | $diff_file_offset++; 31 | 32 | if ($chunk_line->getType() !== \SebastianBergmann\Diff\Line::REMOVED) { 33 | $line_offset++; 34 | } 35 | 36 | if ($input_line === $line_offset + $chunk_end - 1) { 37 | return $diff_file_offset; 38 | } 39 | } 40 | } else { 41 | $diff_file_offset += count($chunk->getLines()); 42 | } 43 | 44 | $diff_file_offset++; 45 | } 46 | } 47 | } 48 | 49 | return null; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /views/index.php: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | Shepherd 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 19 | 20 |
21 |
22 |

Shepherd is a currently-experimental service to handle CI output from Psalm.

23 |

It's being actively developed at github.com/psalm/shepherd.

24 |
25 |
26 |

Recent type coverage

27 | 28 |
    29 | 30 |
  • 31 | 32 |
    33 | 34 | 35 | 36 |
  • 37 | 38 |
39 |
40 |
41 | 42 | 43 | -------------------------------------------------------------------------------- /views/hooks/github.php: -------------------------------------------------------------------------------- 1 | webhook_secret) { 29 | $hash = 'sha1=' . hash_hmac('sha1', $raw_post, $config->webhook_secret, false); 30 | 31 | if (!isset($_SERVER['HTTP_X_HUB_SIGNATURE'])) { 32 | throw new Exception('Missing signature header'); 33 | } 34 | 35 | if (!hash_equals($hash, $_SERVER['HTTP_X_HUB_SIGNATURE'])) { 36 | throw new Exception('Mismatching signature'); 37 | } 38 | } 39 | 40 | $payload = json_decode($raw_payload, true); 41 | 42 | if (!isset($payload['pull_request'])) { 43 | if (($_SERVER['HTTP_X_GITHUB_EVENT'] ?? '') === 'push' 44 | && ($payload['ref'] ?? '') === 'refs/heads/master' 45 | && isset($payload['repository']) 46 | ) { 47 | Psalm\Shepherd\GithubData::storeMasterData( 48 | $payload['after'], 49 | $payload 50 | ); 51 | } 52 | 53 | return; 54 | } 55 | 56 | $git_commit_hash = $payload['pull_request']['head']['sha'] ?? null; 57 | 58 | if (!$git_commit_hash) { 59 | return; 60 | } 61 | 62 | if (!preg_match('/^[a-f0-9]+$/', $git_commit_hash)) { 63 | throw new UnexpectedValueException('Bad git commit hash given'); 64 | } 65 | 66 | Psalm\Shepherd\GithubData::storePullRequestData( 67 | $git_commit_hash, 68 | $payload 69 | ); 70 | -------------------------------------------------------------------------------- /assets/img/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.appveyor.yml: -------------------------------------------------------------------------------- 1 | build: false 2 | clone_folder: c:\shepherd 3 | max_jobs: 3 4 | platform: x86 5 | pull_requests: 6 | do_not_increment_build_number: true 7 | version: '{build}.{branch}' 8 | 9 | environment: 10 | SSL_CERT_FILE: "C:\\php\\cacert.pem" 11 | COMPOSER_ROOT_VERSION: '7.0-dev' 12 | 13 | matrix: 14 | - PHP_VERSION: '7.2' 15 | DEPENDENCIES: '' 16 | 17 | matrix: 18 | fast_finish: true 19 | 20 | cache: 21 | - c:\php -> .appveyor.yml 22 | - '%LOCALAPPDATA%\Composer\files' 23 | 24 | init: 25 | - SET PATH=c:\php\%PHP_VERSION%;%PATH% 26 | 27 | install: 28 | - IF NOT EXIST c:\php mkdir c:\php 29 | - IF NOT EXIST c:\php\%PHP_VERSION% mkdir c:\php\%PHP_VERSION% 30 | - appveyor DownloadFile https://curl.haxx.se/ca/cacert.pem -FileName C:\php\cacert.pem 31 | - cd c:\php\%PHP_VERSION% 32 | - IF NOT EXIST php-installed.txt curl --fail --location --silent --show-error -o php-%PHP_VERSION%-Win32-VC15-x86-latest.zip https://windows.php.net/downloads/releases/latest/php-%PHP_VERSION%-Win32-VC15-x86-latest.zip 33 | - IF NOT EXIST php-installed.txt 7z x php-%PHP_VERSION%*-Win32-VC15-x86-latest.zip -y >nul 34 | - IF NOT EXIST php-installed.txt del /Q *.zip 35 | - IF NOT EXIST php-installed.txt copy /Y php.ini-development php.ini 36 | - IF NOT EXIST php-installed.txt echo max_execution_time=1200 >> php.ini 37 | - IF NOT EXIST php-installed.txt echo date.timezone="UTC" >> php.ini 38 | - IF NOT EXIST php-installed.txt echo extension_dir=ext >> php.ini 39 | - IF NOT EXIST php-installed.txt echo extension=php_curl.dll >> php.ini 40 | - IF NOT EXIST php-installed.txt echo extension=php_openssl.dll >> php.ini 41 | - IF NOT EXIST php-installed.txt echo extension=php_mbstring.dll >> php.ini 42 | - IF NOT EXIST php-installed.txt echo extension=php_fileinfo.dll >> php.ini 43 | - IF NOT EXIST php-installed.txt echo extension=php_mysqli.dll >> php.ini 44 | - IF NOT EXIST php-installed.txt echo extension=php_pdo_sqlite.dll >> php.ini 45 | - IF NOT EXIST php-installed.txt echo zend.assertions=1 >> php.ini 46 | - IF NOT EXIST php-installed.txt echo assert.exception=On >> php.ini 47 | - IF NOT EXIST php-installed.txt echo curl.cainfo="C:/php/cacert.pem" >> php.ini 48 | - IF NOT EXIST php-installed.txt echo openssl.cafile="C:/php/cacert.pem" >> php.ini 49 | - IF NOT EXIST php-installed.txt curl -fsS -o composer.phar https://getcomposer.org/composer.phar 50 | - IF NOT EXIST php-installed.txt echo @php %%~dp0composer.phar %%* > composer.bat 51 | - IF NOT EXIST php-installed.txt type nul >> php-installed.txt 52 | - cd c:\shepherd 53 | - composer update --no-interaction --no-ansi --no-progress --no-suggest --optimize-autoloader --prefer-stable %DEPENDENCIES% 54 | 55 | ## Run the actual test 56 | test_script: 57 | - cd c:\shepherd 58 | - vendor\bin\phpunit tests 59 | - php vendor\vimeo\psalm\psalm --shepherd -------------------------------------------------------------------------------- /views/shields/coverage.php: -------------------------------------------------------------------------------- 1 | type-coveragetype-coverageunknownunknown 23 | SVG; 24 | exit; 25 | } 26 | 27 | if ($pct > 95) { 28 | $color = '#4c1'; 29 | } elseif ($pct > 90) { 30 | $color = '#97ca00'; 31 | } elseif ($pct > 85) { 32 | $color = '#aeaf12'; 33 | } elseif ($pct > 80) { 34 | $color = '#dfb317'; 35 | } elseif ($pct > 75) { 36 | $color = '#fe7d37'; 37 | } else { 38 | $color = '#e05d44'; 39 | } 40 | 41 | $pct = $pct . '%'; 42 | 43 | echo << type-coveragetype-coverage{$pct}{$pct} 45 | SVG; 46 | -------------------------------------------------------------------------------- /src/Auth.php: -------------------------------------------------------------------------------- 1 | personal_token; 27 | } 28 | 29 | $repo_token = self::getTokenForRepo($repository); 30 | 31 | if ($repo_token) { 32 | return $repo_token; 33 | } 34 | 35 | if ($config->public_access_oauth_token) { 36 | return $config->public_access_oauth_token; 37 | } 38 | 39 | throw new \UnexpectedValueException( 40 | 'Could not find valid token for ' . $repository->owner_name . '/' . $repository->repo_name 41 | ); 42 | } 43 | 44 | /** @psalm-suppress UnusedParam */ 45 | private static function getTokenForRepo(Model\GithubRepository $repository) : ?string 46 | { 47 | return null; 48 | } 49 | 50 | public static function fetchTokenFromGithub(string $code, string $state, Config\OAuthApp $config) : string 51 | { 52 | $params = [ 53 | 'client_id' => $config->client_id, 54 | 'client_secret' => $config->client_secret, 55 | 'code' => $code, 56 | 'state' => $state, 57 | ]; 58 | 59 | $payload = http_build_query($params); 60 | 61 | $github_url = $config->gh_enterprise_url ?: 'https://github.com'; 62 | 63 | // Prepare new cURL resource 64 | $ch = curl_init($github_url . '/login/oauth/access_token'); 65 | curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); 66 | curl_setopt($ch, CURLINFO_HEADER_OUT, true); 67 | curl_setopt($ch, CURLOPT_POST, true); 68 | curl_setopt($ch, CURLOPT_POSTFIELDS, $payload); 69 | curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, 0); 70 | 71 | // Set HTTP Header for POST request 72 | curl_setopt( 73 | $ch, 74 | CURLOPT_HTTPHEADER, 75 | [ 76 | 'Accept: application/json', 77 | 'Content-Type: application/x-www-form-urlencoded', 78 | 'Content-Length: ' . strlen($payload) 79 | ] 80 | ); 81 | 82 | // Submit the POST request 83 | $response = (string) curl_exec($ch); 84 | 85 | // Close cURL session handle 86 | curl_close($ch); 87 | 88 | if (!$response) { 89 | throw new \UnexpectedValueException('Response should exist'); 90 | } 91 | 92 | $response_data = json_decode($response, true); 93 | 94 | return $response_data['access_token']; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /views/shields/level.php: -------------------------------------------------------------------------------- 1 | Psalmenabled 23 | SVG; 24 | exit; 25 | } 26 | 27 | if ($level === 1) { 28 | $color = '#4c1'; 29 | } elseif ($level === 2 || $level === 3) { 30 | $color = '#97ca00'; 31 | } elseif ($level === 4) { 32 | $color = '#aeaf12'; 33 | } elseif ($level === 5) { 34 | $color = '#dfb317'; 35 | } elseif ($level === 6) { 36 | $color = '#fe7d37'; 37 | } else { 38 | $color = '#e05d44'; 39 | } 40 | 41 | echo <<Psalm level{$level} 43 | SVG; 44 | -------------------------------------------------------------------------------- /init.sql: -------------------------------------------------------------------------------- 1 | CREATE DATABASE IF NOT EXISTS `shepherd_web`; 2 | 3 | GRANT ALL ON `shepherd_web`.* TO 'shepherd_user'@'%' IDENTIFIED BY 'shepherd_mysql_development_password'; 4 | 5 | CREATE TABLE `shepherd_web`.`test_failures` ( 6 | `id` int(11) unsigned NOT NULL AUTO_INCREMENT, 7 | `git_commit` varchar(40) NOT NULL DEFAULT '', 8 | `test_name` varchar(255) NOT NULL DEFAULT '', 9 | `branch` varchar(127) NOT NULL DEFAULT '', 10 | `repository` varchar(255) DEFAULT '', 11 | `created_on` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, 12 | PRIMARY KEY (`id`), 13 | UNIQUE KEY `commit_test` (`git_commit`,`test_name`), 14 | KEY `test_name` (`test_name`), 15 | KEY `git_commit` (`git_commit`) 16 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8; 17 | 18 | CREATE TABLE `shepherd_web`.`github_pr_reviews` ( 19 | `id` int(11) unsigned NOT NULL AUTO_INCREMENT, 20 | `github_pr_url` varchar(255) NOT NULL DEFAULT '', 21 | `tool` enum('psalm','phpunit') NOT NULL DEFAULT 'psalm', 22 | `github_review_id` varchar(255) NOT NULL DEFAULT '', 23 | `created_on` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, 24 | PRIMARY KEY (`id`), 25 | UNIQUE KEY `review_for_tool` (`github_pr_url`,`tool`) 26 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8; 27 | 28 | CREATE TABLE `shepherd_web`.`github_pr_comments` ( 29 | `id` int(11) unsigned NOT NULL AUTO_INCREMENT, 30 | `github_pr_url` varchar(255) NOT NULL DEFAULT '', 31 | `tool` enum('psalm','phpunit') NOT NULL DEFAULT 'psalm', 32 | `github_comment_id` varchar(255) NOT NULL, 33 | `created_on` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, 34 | PRIMARY KEY (`id`), 35 | KEY `github_comment_tool` (`github_pr_url`,`tool`) 36 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8; 37 | 38 | CREATE TABLE `shepherd_web`.`github_pull_requests` ( 39 | `id` int(11) unsigned NOT NULL AUTO_INCREMENT, 40 | `git_commit` varchar(40) NOT NULL DEFAULT '', 41 | `owner_name` varchar(127) NOT NULL DEFAULT '', 42 | `repo_name` varchar(127) NOT NULL DEFAULT '', 43 | `number` int(6) unsigned NOT NULL, 44 | `branch` varchar(127) NOT NULL DEFAULT '', 45 | `url` varchar(255) NOT NULL DEFAULT '', 46 | `created_on` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, 47 | PRIMARY KEY (`id`), 48 | UNIQUE KEY `git_commit` (`git_commit`) 49 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8; 50 | 51 | CREATE TABLE `shepherd_web`.`psalm_reports` ( 52 | `id` int(11) unsigned NOT NULL AUTO_INCREMENT, 53 | `git_commit` varchar(40) NOT NULL DEFAULT '', 54 | `issues` mediumblob NOT NULL, 55 | `mixed_count` int(10) unsigned NOT NULL, 56 | `nonmixed_count` int(10) unsigned NOT NULL, 57 | `created_on` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, 58 | PRIMARY KEY (`id`), 59 | UNIQUE KEY `git_commit` (`git_commit`) 60 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8; 61 | 62 | CREATE TABLE `shepherd_web`.`github_master_commits` ( 63 | `id` int(11) unsigned NOT NULL AUTO_INCREMENT, 64 | `git_commit` varchar(40) NOT NULL, 65 | `owner_name` varchar(127) NOT NULL DEFAULT '', 66 | `repo_name` varchar(127) NOT NULL DEFAULT '', 67 | `created_on` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, 68 | PRIMARY KEY (`id`), 69 | UNIQUE KEY `git_commit` (`git_commit`) 70 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8; 71 | -------------------------------------------------------------------------------- /views/history.php: -------------------------------------------------------------------------------- 1 | 1000) { 17 | $x_number_format = number_format($x); 18 | $x_array = explode(',', $x_number_format); 19 | $x_parts = ['K', 'M', 'B', 'T']; 20 | $x_count_parts = count($x_array) - 1; 21 | $x_display = $x_array[0] . ((int) $x_array[1][0] !== 0 ? '.' . $x_array[1][0] : ''); 22 | $x_display .= $x_parts[$x_count_parts - 1]; 23 | 24 | return $x_display; 25 | } 26 | 27 | return (string) $x; 28 | }; 29 | 30 | $pct = Psalm\Shepherd\Api::getHistory($repository); 31 | 32 | $config = Psalm\Shepherd\Config::getInstance(); 33 | 34 | $github_url = $config->gh_enterprise_url ?: 'https://github.com'; 35 | ?> 36 | 37 | 38 | Shepherd - <?php echo $repository ?> 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 51 | 52 |
53 |
54 |

55 | 56 |

57 | 58 |

Type coverage history

59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | [$commit, $coverage, $total]) : ?> 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 |
DateCommitType CoverageAnalysed Expression #
%
80 |
81 |
82 | 83 | 84 | -------------------------------------------------------------------------------- /tests/GithubDiffLineTest.php: -------------------------------------------------------------------------------- 1 | assertSame( 18 | \Psalm\Shepherd\DiffLineFinder::getGitHubPositionFromDiff( 19 | $line_from, 20 | $file_name, 21 | file_get_contents(__DIR__ . '/test.diff') 22 | ), 23 | $expected 24 | ); 25 | } 26 | 27 | public function providerDiffLines() : array 28 | { 29 | return [ 30 | [ 31 | 1495, 32 | 'src/Psalm/Internal/Analyzer/ClassAnalyzer.php', 33 | null 34 | ], 35 | [ 36 | 1496, 37 | 'src/Psalm/Internal/Analyzer/ClassAnalyzer.php', 38 | 1 39 | ], 40 | [ 41 | 1498, 42 | 'src/Psalm/Internal/Analyzer/ClassAnalyzer.php', 43 | 3 44 | ], 45 | [ 46 | 1499, 47 | 'src/Psalm/Internal/Analyzer/ClassAnalyzer.php', 48 | 7 49 | ], 50 | [ 51 | 1500, 52 | 'src/Psalm/Internal/Analyzer/ClassAnalyzer.php', 53 | 8 54 | ], 55 | [ 56 | 1502, 57 | 'src/Psalm/Internal/Analyzer/ClassAnalyzer.php', 58 | 10 59 | ], 60 | [ 61 | 1503, 62 | 'src/Psalm/Internal/Analyzer/ClassAnalyzer.php', 63 | null 64 | ], 65 | [ 66 | 615, 67 | 'src/Psalm/Internal/Analyzer/MethodAnalyzer.php', 68 | 3 69 | ], 70 | [ 71 | 616, 72 | 'src/Psalm/Internal/Analyzer/MethodAnalyzer.php', 73 | 5 74 | ], 75 | [ 76 | 617, 77 | 'src/Psalm/Internal/Analyzer/MethodAnalyzer.php', 78 | 6 79 | ], 80 | [ 81 | 618, 82 | 'src/Psalm/Internal/Analyzer/MethodAnalyzer.php', 83 | 8 84 | ], 85 | [ 86 | 619, 87 | 'src/Psalm/Internal/Analyzer/MethodAnalyzer.php', 88 | 9 89 | ], 90 | [ 91 | 622, 92 | 'src/Psalm/Internal/Analyzer/MethodAnalyzer.php', 93 | null 94 | ], 95 | [ 96 | 637, 97 | 'src/Psalm/Internal/Analyzer/MethodAnalyzer.php', 98 | null 99 | ], 100 | [ 101 | 638, 102 | 'src/Psalm/Internal/Analyzer/MethodAnalyzer.php', 103 | 13 104 | ], 105 | [ 106 | 641, 107 | 'src/Psalm/Internal/Analyzer/MethodAnalyzer.php', 108 | 17 109 | ], 110 | ]; 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /tests/Extension/FailureTracker.php: -------------------------------------------------------------------------------- 1 | */ 37 | private $failed_tests = []; 38 | 39 | public function executeAfterTestFailure(string $test, string $message, float $time): void 40 | { 41 | $this->failed_tests[] = $test; 42 | } 43 | 44 | public function executeAfterTestError(string $test, string $message, float $time): void 45 | { 46 | $this->failed_tests[] = $test; 47 | } 48 | 49 | public function executeAfterLastTest(): void 50 | { 51 | if (!$this->failed_tests) { 52 | return; 53 | } 54 | 55 | $build_info = self::getBuildInfo(); 56 | $git_info = self::getGitInfo(); 57 | 58 | if ($build_info) { 59 | $data = [ 60 | 'build' => $build_info, 61 | 'git' => $git_info->toArray(), 62 | 'tests' => $this->failed_tests, 63 | ]; 64 | 65 | $payload = json_encode($data); 66 | 67 | $base_address = 'https://shepherd.dev'; 68 | 69 | if (parse_url($base_address, PHP_URL_SCHEME) === null) { 70 | $base_address = 'https://' . $base_address; 71 | } 72 | 73 | // Prepare new cURL resource 74 | $ch = curl_init($base_address . '/hooks/phpunit'); 75 | curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); 76 | curl_setopt($ch, CURLINFO_HEADER_OUT, true); 77 | curl_setopt($ch, CURLOPT_POST, true); 78 | curl_setopt($ch, CURLOPT_POSTFIELDS, $payload); 79 | 80 | // Set HTTP Header for POST request 81 | curl_setopt( 82 | $ch, 83 | CURLOPT_HTTPHEADER, 84 | [ 85 | 'Content-Type: application/json', 86 | 'Content-Length: ' . strlen($payload), 87 | ] 88 | ); 89 | 90 | // Submit the POST request 91 | $return = curl_exec($ch); 92 | 93 | if ($return !== '') { 94 | fwrite(STDERR, 'Error with PHPUnit Shepherd:' . PHP_EOL); 95 | 96 | if ($return === false) { 97 | fwrite(STDERR, \Psalm\Plugin\Shepherd::getCurlErrorMessage($ch) . PHP_EOL); 98 | } else { 99 | echo $return . PHP_EOL; 100 | echo 'Git args: ' . var_export($git_info->toArray(), true) . PHP_EOL; 101 | echo 'CI args: ' . var_export($build_info, true) . PHP_EOL; 102 | } 103 | } else { 104 | var_dump('successfully sent'); 105 | } 106 | 107 | // Close cURL session handle 108 | curl_close($ch); 109 | } 110 | } 111 | 112 | private static function getBuildInfo(): array 113 | { 114 | if (!self::$build_info) { 115 | self::$build_info = (new \Psalm\Internal\ExecutionEnvironment\BuildInfoCollector($_SERVER))->collect(); 116 | } 117 | 118 | return self::$build_info; 119 | } 120 | 121 | private static function getGitInfo(): GitInfo 122 | { 123 | if (!self::$git_info) { 124 | self::$git_info = (new \Psalm\Internal\ExecutionEnvironment\GitInfoCollector())->collect(); 125 | } 126 | 127 | return self::$git_info; 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/Api.php: -------------------------------------------------------------------------------- 1 | prepare( 18 | 'SELECT mixed_count, nonmixed_count 19 | FROM psalm_reports 20 | INNER JOIN github_master_commits ON `github_master_commits`.`git_commit` = `psalm_reports`.`git_commit` 21 | WHERE owner_name = :owner_name 22 | AND repo_name = :repo_name 23 | ORDER BY `github_master_commits`.`created_on` DESC' 24 | ); 25 | 26 | $stmt->bindValue(':owner_name', $owner_name); 27 | $stmt->bindValue(':repo_name', $repo_name); 28 | 29 | $stmt->execute(); 30 | 31 | /** @var array */ 32 | $row = $stmt->fetch(PDO::FETCH_ASSOC); 33 | 34 | if (!$row) { 35 | return null; 36 | } 37 | 38 | $total = $row['mixed_count'] + $row['nonmixed_count']; 39 | if (!$total) { 40 | return '0'; 41 | } 42 | 43 | $fraction = $row['nonmixed_count'] / $total; 44 | 45 | if ($fraction >= 0.9995) { 46 | return '100'; 47 | } 48 | 49 | return number_format(100 * $fraction, 1); 50 | } 51 | 52 | public static function getLevel(string $repository) : ?int 53 | { 54 | list($owner_name, $repo_name) = explode('/', $repository); 55 | 56 | $connection = DatabaseProvider::getConnection(); 57 | 58 | $stmt = $connection->prepare( 59 | 'SELECT level 60 | FROM psalm_reports 61 | INNER JOIN github_master_commits ON `github_master_commits`.`git_commit` = `psalm_reports`.`git_commit` 62 | WHERE owner_name = :owner_name 63 | AND repo_name = :repo_name 64 | ORDER BY `github_master_commits`.`created_on` DESC' 65 | ); 66 | 67 | $stmt->bindValue(':owner_name', $owner_name); 68 | $stmt->bindValue(':repo_name', $repo_name); 69 | 70 | $stmt->execute(); 71 | 72 | /** @var array */ 73 | $row = $stmt->fetch(PDO::FETCH_ASSOC); 74 | 75 | if (!$row) { 76 | return null; 77 | } 78 | 79 | return $row['level']; 80 | } 81 | 82 | public static function getHistory(string $repository) : array 83 | { 84 | list($owner_name, $repo_name) = explode('/', $repository); 85 | 86 | $connection = DatabaseProvider::getConnection(); 87 | 88 | $stmt = $connection->prepare( 89 | 'SELECT `github_master_commits`.`git_commit`, 90 | mixed_count, 91 | nonmixed_count, 92 | `github_master_commits`.created_on 93 | FROM psalm_reports 94 | INNER JOIN github_master_commits ON `github_master_commits`.`git_commit` = `psalm_reports`.`git_commit` 95 | WHERE owner_name = :owner_name 96 | AND repo_name = :repo_name 97 | ORDER BY `github_master_commits`.`created_on` DESC' 98 | ); 99 | 100 | $stmt->bindValue(':owner_name', $owner_name); 101 | $stmt->bindValue(':repo_name', $repo_name); 102 | 103 | $stmt->execute(); 104 | 105 | $history = []; 106 | 107 | /** @var array{git_commit: string, mixed_count: int, nonmixed_count: int, created_on: string} $row */ 108 | foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { 109 | $total = ($row['mixed_count'] + $row['nonmixed_count']); 110 | if (!$row['mixed_count'] && $row['nonmixed_count']) { 111 | $c = 100; 112 | } elseif ($total) { 113 | $c = 100 * $row['nonmixed_count'] / $total; 114 | } else { 115 | $c = 0; 116 | } 117 | 118 | $history[$row['created_on']] = [$row['git_commit'], $c, $total]; 119 | } 120 | 121 | return $history; 122 | } 123 | 124 | /** @return string[] */ 125 | public static function getRecentGithubRepositories() : array 126 | { 127 | $repositories = []; 128 | 129 | $connection = DatabaseProvider::getConnection(); 130 | 131 | $stmt = $connection->prepare( 132 | 'SELECT owner_name, repo_name, max(`github_master_commits`.created_on) as last_updated 133 | FROM psalm_reports 134 | INNER JOIN github_master_commits ON `github_master_commits`.`git_commit` = `psalm_reports`.`git_commit` 135 | GROUP BY owner_name, repo_name 136 | ORDER BY last_updated DESC 137 | LIMIT 5' 138 | ); 139 | 140 | $stmt->execute(); 141 | 142 | /** @var array{owner_name: string, repo_name: string} $row */ 143 | foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { 144 | $repositories[] = $row['owner_name'] . '/' . $row['repo_name']; 145 | } 146 | 147 | return $repositories; 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /src/Config.php: -------------------------------------------------------------------------------- 1 | $val) { 82 | // for shepherd vars 83 | if (strpos($var_name, 'SHEPHERD') === 0) { 84 | $var_parts = explode('_', $var_name, 2); 85 | // save the suffix, we'll need it later to tell if its a config group, or root level config item 86 | $var_name_suffix = $var_parts[1]; 87 | // this var is either a config group, or root level item 88 | $is_a_config_group = false; 89 | // check the suffix to see if it begins with any of these config group names 90 | foreach (['CUSTOM', 'OAUTH_APP', 'MYSQL'] as $config_group) { 91 | if (strpos($var_name_suffix, $config_group) === 0) { 92 | // remember that this var was a config group, so no need to set it as a root level item later 93 | $is_a_config_group = true; 94 | $group_name = strtolower($config_group); 95 | $key_name = strtolower( 96 | substr($var_name_suffix, strlen($config_group) + 1) 97 | ); 98 | // set the item as a child of the config group in the $config structure initialized earlier 99 | $config[$group_name][$key_name] = $val; 100 | } 101 | } 102 | // if the item was not a config group item, set it as a root level item in $config 103 | if ($is_a_config_group === false) { 104 | $config[ strtolower($var_name_suffix) ] = $val; 105 | } 106 | } 107 | } 108 | 109 | // initialize the config with all the SHEPHERD_ vars we got from $_ENV 110 | return self::initializeConfig($config); 111 | } 112 | 113 | /** @return Config\Custom|Config\OAuthApp */ 114 | private static function initializeConfig(Array $config) 115 | { 116 | if (isset($config['custom']['personal_token'])) { 117 | return self::$config = new Config\Custom( 118 | $config['custom']['personal_token'], 119 | $config['custom']['webhook_secret'] ?? null, 120 | $config['gh_enterprise_url'] ?? null, 121 | $config['mysql'] 122 | ); 123 | } 124 | 125 | if (isset($config['oauth_app']['client_id']) && isset($config['oauth_app']['client_secret'])) { 126 | return self::$config = new Config\OAuthApp( 127 | $config['oauth_app']['client_id'], 128 | $config['oauth_app']['client_secret'], 129 | $config['gh_enterprise_url'] ?? null, 130 | $config['oauth_app']['public_access_oauth_token'] ?? null, 131 | $config['mysql'] 132 | ); 133 | } 134 | 135 | throw new \UnexpectedValueException('Invalid config'); 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/PsalmPlugin/GitApiReturnTypeProvider.php: -------------------------------------------------------------------------------- 1 | $call_args 26 | */ 27 | public static function getMethodReturnType(MethodReturnTypeProviderEvent $event): ?Type\Union { 28 | $source = $event->getSource(); 29 | $method_name_lowercase = $event->getMethodNameLowercase(); 30 | $call_args = $event->getCallArgs(); 31 | 32 | $node_provider = $source->getNodeTypeProvider(); 33 | 34 | if ($method_name_lowercase === 'api' 35 | && isset($call_args[0]) 36 | && ($first_arg_type = $node_provider->getType($call_args[0]->value)) 37 | && $first_arg_type->isSingleStringLiteral() 38 | ) { 39 | switch ($first_arg_type->getSingleStringLiteral()->value) { 40 | case 'me': 41 | case 'current_user': 42 | case 'currentUser': 43 | $api = Api\CurrentUser::class; 44 | break; 45 | case 'codeOfConduct': 46 | $api = Api\Miscellaneous\CodeOfConduct::class; 47 | break; 48 | 49 | case 'deployment': 50 | case 'deployments': 51 | $api = Api\Deployment::class; 52 | break; 53 | 54 | case 'ent': 55 | case 'enterprise': 56 | $api = Api\Enterprise::class; 57 | break; 58 | 59 | case 'emojis': 60 | $api = Api\Miscellaneous\Emojis::class; 61 | break; 62 | 63 | case 'git': 64 | case 'git_data': 65 | case 'gitData': 66 | $api = Api\GitData::class; 67 | break; 68 | 69 | case 'gist': 70 | case 'gists': 71 | $api = Api\Gists::class; 72 | break; 73 | 74 | case 'gitignore': 75 | $api = Api\Miscellaneous\Gitignore::class; 76 | break; 77 | 78 | case 'apps': 79 | $api = Api\Apps::class; 80 | break; 81 | 82 | case 'issue': 83 | case 'issues': 84 | $api = Api\Issue::class; 85 | break; 86 | 87 | case 'markdown': 88 | $api = Api\Markdown::class; 89 | break; 90 | 91 | case 'licenses': 92 | $api = Api\Miscellaneous\Licenses::class; 93 | break; 94 | 95 | case 'notification': 96 | case 'notifications': 97 | $api = Api\Notification::class; 98 | break; 99 | 100 | case 'organization': 101 | case 'organizations': 102 | $api = Api\Organization::class; 103 | break; 104 | 105 | case 'org_project': 106 | case 'orgProject': 107 | case 'org_projects': 108 | case 'orgProjects': 109 | case 'organization_project': 110 | case 'organizationProject': 111 | case 'organization_projects': 112 | case 'organizationProjects': 113 | $api = Api\Organization\Projects::class; 114 | break; 115 | 116 | case 'pr': 117 | case 'pulls': 118 | case 'pullRequest': 119 | case 'pull_request': 120 | case 'pullRequests': 121 | case 'pull_requests': 122 | $api = Api\PullRequest::class; 123 | break; 124 | 125 | case 'rateLimit': 126 | case 'rate_limit': 127 | $api = Api\RateLimit::class; 128 | break; 129 | 130 | case 'repo': 131 | case 'repos': 132 | case 'repository': 133 | case 'repositories': 134 | $api = Api\Repo::class; 135 | break; 136 | 137 | case 'search': 138 | $api = Api\Search::class; 139 | break; 140 | 141 | case 'team': 142 | case 'teams': 143 | $api = Api\Organization\Teams::class; 144 | break; 145 | 146 | case 'member': 147 | case 'members': 148 | $api = Api\Organization\Members::class; 149 | break; 150 | 151 | case 'user': 152 | case 'users': 153 | $api = Api\User::class; 154 | break; 155 | 156 | case 'authorization': 157 | case 'authorizations': 158 | $api = Api\Authorizations::class; 159 | break; 160 | 161 | case 'meta': 162 | $api = Api\Meta::class; 163 | break; 164 | 165 | case 'graphql': 166 | $api = Api\GraphQL::class; 167 | break; 168 | 169 | default: 170 | return null; 171 | } 172 | 173 | return Type::parseString($api); 174 | } 175 | 176 | return null; 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /src/PsalmData.php: -------------------------------------------------------------------------------- 1 | prepare( 80 | 'INSERT IGNORE INTO psalm_reports (`git_commit`, `issues`, `mixed_count`, `nonmixed_count`, `level`) 81 | VALUES (:git_commit, :issues, :mixed_count, :nonmixed_count, :level)' 82 | ); 83 | 84 | $stmt->bindValue(':git_commit', $git_commit); 85 | $stmt->bindValue(':issues', json_encode($issues)); 86 | $stmt->bindValue(':mixed_count', $mixed_count); 87 | $stmt->bindValue(':nonmixed_count', $nonmixed_count); 88 | $stmt->bindValue(':level', $level); 89 | 90 | $stmt->execute(); 91 | } 92 | 93 | /** @param array $issues 96 | */ 97 | private static function getGithubReviewForIssues(array $issues, string $diff_string) : Model\GithubReview 98 | { 99 | $file_comments = []; 100 | 101 | $missed_errors = []; 102 | 103 | foreach ($issues as $issue) { 104 | if ($issue['severity'] !== 'error') { 105 | continue; 106 | } 107 | 108 | $file_name = $issue['file_name']; 109 | $line_from = $issue['line_from']; 110 | 111 | $diff_file_offset = DiffLineFinder::getGitHubPositionFromDiff( 112 | $line_from, 113 | $file_name, 114 | $diff_string 115 | ); 116 | 117 | if ($diff_file_offset !== null) { 118 | $snippet = $issue['snippet']; 119 | $selected_text = $issue['selected_text']; 120 | 121 | $selection_start = $issue['from'] - $issue['snippet_from']; 122 | $selection_length = $issue['to'] - $issue['from']; 123 | 124 | $before_selection = substr($snippet, 0, $selection_start); 125 | 126 | $after_selection = substr($snippet, $selection_start + $selection_length); 127 | 128 | $before_lines = explode("\n", $before_selection); 129 | 130 | $last_before_line_length = strlen(array_pop($before_lines)); 131 | 132 | $first_selected_line = explode("\n", $selected_text)[0]; 133 | 134 | if ($first_selected_line === $selected_text) { 135 | $first_selected_line .= explode("\n", $after_selection)[0]; 136 | } 137 | 138 | $issue_string = $before_selection . $first_selected_line 139 | . "\n" . str_repeat(' ', $last_before_line_length) . str_repeat('^', strlen($selected_text)); 140 | 141 | $file_comments[] = [ 142 | 'path' => $file_name, 143 | 'position' => $diff_file_offset, 144 | 'body' => $issue['message'] . "\n```\n" 145 | . $issue_string . "\n```", 146 | ]; 147 | 148 | continue; 149 | } 150 | 151 | $missed_errors[] = $file_name . ':' . $line_from . ':' . $issue['column_from'] . ' - ' . $issue['message']; 152 | } 153 | 154 | if ($missed_errors) { 155 | $comment_text = "\n\n```\n" . implode("\n", $missed_errors) . "\n```"; 156 | 157 | if ($file_comments) { 158 | $message_body = 'Psalm also found errors in other files' . $comment_text; 159 | } else { 160 | $message_body = 'Psalm found errors in other files' . $comment_text; 161 | } 162 | } elseif ($file_comments) { 163 | $message_body = 'Psalm found some errors'; 164 | } else { 165 | $message_body = 'Psalm didn’t find any errors!'; 166 | } 167 | 168 | return new Model\GithubReview( 169 | $message_body, 170 | !$missed_errors && !$file_comments, 171 | $file_comments 172 | ); 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /assets/styles/main.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: 'IBM Plex Sans', sans-serif; 3 | line-height: 1.5em; 4 | margin: 0; 5 | padding: 0; 6 | font-size: 16px; 7 | min-height: 100%; 8 | display: flex; 9 | flex-direction: column; 10 | } 11 | .container { 12 | max-width: 1024px; 13 | margin: 0 auto; 14 | flex: 1; 15 | } 16 | 17 | .container.front { 18 | padding-top: 50px; 19 | } 20 | 21 | nav { 22 | overflow: hidden; 23 | padding: 10px 20px; 24 | background-color: #ccc; 25 | border-bottom: 2px solid #666; 26 | 27 | background-image: repeating-linear-gradient(45deg, #666 25%, transparent 25%, transparent 75%, #666 75%, #666), repeating-linear-gradient(45deg, #666 25%, #FFF 25%, #FFF 75%, #666 75%, #666); 28 | background-position: 0 0, 1px 1px; 29 | background-size: 2px 2px; 30 | } 31 | 32 | h1 { 33 | margin: 20px 0 20px 20px; 34 | float: left; 35 | font-size: 40px; 36 | font-family: 'IBM Plex Mono', monospace; 37 | font-weight: 400; 38 | text-shadow: 0px 2px 0 white; 39 | position: relative; 40 | } 41 | 42 | h2, h3 { 43 | font-family: 'IBM Plex Sans', sans-serif; 44 | font-weight: normal; 45 | } 46 | 47 | h1 a { 48 | color: #000; 49 | text-decoration: none; 50 | } 51 | 52 | h1 a:after { 53 | content: ''; 54 | display: block; 55 | width: 48px; 56 | height: 48px; 57 | position: absolute; 58 | top: -12px; 59 | right: -60px; 60 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='48' height='48' viewBox='0 0 31 31'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cpath d='M15.3456 1.0014c-.7453-.0151-1.3938.425-1.9659.8543-.4419.3342-.9465.6678-1.5252.6318-.8315-.0358-1.6584-.3646-2.4929-.166-.7056.1443-1.204.7321-1.5298 1.3382-.3328.5846-.603 1.2898-1.2495 1.5982-.7596.3628-1.6656.3838-2.347.917-.6112.4461-.8385 1.2254-.8719 1.9496-.0512.6567.02 1.3957-.4186 1.9438-.5156.6388-1.2885 1.048-1.6842 1.7884-.3628.6206-.3098 1.3932-.047 2.0403.2293.648.6547 1.2827.5568 1.9993-.134.8112-.6744 1.4996-.7525 2.3268-.0966.6811.2075 1.3572.684 1.8326.4492.4965 1.0787.836 1.4005 1.441.3046.671.1558 1.431.2915 2.1376.0921.6733.462 1.324 1.0694 1.6562.6806.4178 1.5185.435 2.2115.8204.6152.3752.8414 1.1017 1.2099 1.6806.3534.6155.932 1.1598 1.6622 1.2488.8154.13 1.6032-.2211 2.4143-.21.6908.0294 1.227.5282 1.775.8902.5133.361 1.1184.6606 1.7635.5994.7748-.055 1.4085-.5524 2.0116-.9933.4013-.3026.8858-.5394 1.401-.488.834.0608 1.6765.3786 2.5107.1401.721-.1878 1.1896-.8285 1.5148-1.4577.2996-.5624.5994-1.2025 1.2164-1.4769.7335-.336 1.5953-.3654 2.2575-.8619.6059-.4196.8643-1.1669.9097-1.8753.0674-.6775-.0369-1.4393.3997-2.0177.5148-.6587 1.3064-1.0703 1.7095-1.823.3646-.6202.307-1.3921.0487-2.04-.2346-.6612-.6755-1.3136-.5492-2.0464.158-.8256.7141-1.5352.7555-2.391.0663-.71-.3126-1.374-.817-1.8445-.4542-.4657-1.0771-.8108-1.3302-1.4392-.2511-.7189-.0938-1.5021-.2824-2.2346-.1352-.6693-.5921-1.2601-1.217-1.541-.6681-.3446-1.4635-.3604-2.1027-.7667-.62-.4412-.8214-1.2207-1.2475-1.8171-.3622-.565-.9483-1.0232-1.636-1.073-.7977-.0963-1.5649.2414-2.3587.2188-.6567-.0408-1.1696-.5103-1.6938-.8559-.4873-.3377-1.0411-.6593-1.6547-.6342h0z' stroke='%23000' fill='%23FFF' fill-rule='nonzero'%3E%3C/path%3E%3Cpath d='M10.9053 8.6924c-1.822-.911-3.6442-.911-5.2385 0 1.5943 2.2776 3.4164 2.9609 5.2385 2.2776.911 2.2776 1.1388 8.427 4.3274 8.427s3.6441-5.9217 4.5551-8.6547c1.5943.6832 3.1886-.2278 4.783-2.2776-1.8221-.6833-3.4164-.6833-5.2385 0-.6833-1.5943-2.0498-2.2776-4.0996-2.2776s-2.7331.6833-4.3274 2.5053z' stroke='%23000' stroke-width='.911' fill='%23000' fill-rule='nonzero'%3E%3C/path%3E%3Cpath d='M13.8661 14.8419c.2278.6833.911.911 1.5943.911s1.1388-.2277 1.3666-.911' stroke='%23FFF' stroke-width='.5'%3E%3C/path%3E%3Cpath fill='%23FFF' fill-rule='nonzero' d='M14.9617 15.9006h.7697l-.154 1.34h-.4618z'%3E%3C/path%3E%3Cpath d='M14.0232 17.5643c.362-.2158.8031-.3238 1.3234-.3238.5202 0 1.0136.108 1.4804.3238' stroke='%23FFF' stroke-width='.5'%3E%3C/path%3E%3Ccircle stroke='%23000' stroke-width='.3' fill='%23FFF' cx='12.8199' cy='10.5608' r='1.9453'%3E%3C/circle%3E%3Ccircle stroke='%23000' stroke-width='.3' fill='%23FFF' cx='17.7112' cy='10.5608' r='1.9453'%3E%3C/circle%3E%3Ccircle fill='%23000' cx='13.0776' cy='10.5608' r='1.1259'%3E%3C/circle%3E%3Ccircle fill='%23000' cx='17.552' cy='10.5608' r='1.1259'%3E%3C/circle%3E%3C/g%3E%3C/svg%3E"); 61 | } 62 | 63 | h1 svg { 64 | vertical-align: text-top; 65 | margin-right: -10px; 66 | } 67 | 68 | nav ul { 69 | list-style: none; 70 | float: right; 71 | margin-top: 25px; 72 | margin: 25px 20px 0 0; 73 | } 74 | 75 | nav ul li { 76 | display: inline-block; 77 | margin-left: 25px; 78 | } 79 | 80 | nav ul li a { 81 | color: #000; 82 | text-decoration: none; 83 | display: block; 84 | padding: 10px; 85 | font-style: normal; 86 | font-weight: 400; 87 | font-size: 16px; 88 | } 89 | 90 | nav ul li a svg { 91 | vertical-align: middle; 92 | display: inline-block; 93 | margin-top: -3px; 94 | margin-right: 5px; 95 | } 96 | 97 | code { 98 | font-family: "IBM Plex Mono"; 99 | font-style: normal; 100 | font-weight: 400; 101 | line-height: 1.6em; 102 | } 103 | 104 | .intro { 105 | max-width: 492px; 106 | padding: 0 20px 50px; 107 | float: left; 108 | } 109 | 110 | .coverage_list ul { 111 | list-style: none; 112 | margin: 0; 113 | padding: 0; 114 | font-family: "IBM Plex Mono"; 115 | font-size: 14px; 116 | } 117 | 118 | .coverage_list li { 119 | margin: 0 0 .5em; 120 | } 121 | 122 | .coverage_list ul a { 123 | text-decoration: none; 124 | } 125 | 126 | .coverage_history { 127 | float: left; 128 | width: 480px; 129 | } 130 | 131 | @media all and (max-width: 1021px) { 132 | nav { 133 | padding: 15px; 134 | margin-bottom: 10px; 135 | } 136 | 137 | h1 { 138 | margin: 10px; 139 | float: none; 140 | } 141 | 142 | h1 a { 143 | padding: 5px 0; 144 | } 145 | 146 | nav ul, 147 | hgroup { 148 | float: none; 149 | margin: 0; 150 | padding: 0; 151 | } 152 | 153 | nav ul li { 154 | margin: 0; 155 | } 156 | nav ul li.get_started { 157 | display: none; 158 | } 159 | 160 | } 161 | 162 | table { 163 | border-collapse: collapse; 164 | } 165 | 166 | th, td { 167 | text-align: left; 168 | padding: .5em; 169 | border: 1px solid #ccc; 170 | } 171 | 172 | footer { 173 | box-sizing: border-box; 174 | padding: 17px 15px 10px; 175 | margin-top: 50px; 176 | text-align: center; 177 | clear: both; 178 | background-position: 0 0; 179 | background-repeat: repeat-x; 180 | background-size: 11px 4px; 181 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 10'%3E%3Cpath fill='none' stroke='%23434343' stroke-width='3' d='M0 8.5c8 0 8-7 16-7s8 7 16 7 8-7 16-7 8 7 16 7' class='st0'/%3E%3C/svg%3E"); 182 | } 183 | 184 | .footer p { 185 | margin: 0; 186 | } 187 | 188 | .vimeo_logo { 189 | display: inline-block; 190 | vertical-align: middle; 191 | margin: 0 3px 10px; 192 | } 193 | 194 | footer a:hover svg path { 195 | fill: #4bf; 196 | } 197 | -------------------------------------------------------------------------------- /src/GithubData.php: -------------------------------------------------------------------------------- 1 | prepare( 16 | 'INSERT IGNORE INTO github_pull_requests 17 | (`owner_name`, `repo_name`, `git_commit`, `number`, `branch`, `url`) 18 | VALUES (:owner_name, :repo_name, :git_commit, :number, :branch, :url)' 19 | ); 20 | 21 | $stmt->bindValue(':git_commit', $git_commit); 22 | $stmt->bindValue(':owner_name', $github_data['pull_request']['base']['repo']['owner']['login']); 23 | $stmt->bindValue(':repo_name', $github_data['pull_request']['base']['repo']['name']); 24 | $stmt->bindValue(':number', $github_data['pull_request']['number']); 25 | $stmt->bindValue(':branch', $github_data['pull_request']['head']['ref']); 26 | $stmt->bindValue(':url', $github_data['pull_request']['html_url']); 27 | 28 | $stmt->execute(); 29 | 30 | error_log('GitHub PR data saved for ' . $git_commit); 31 | } 32 | 33 | public static function storeMasterData(string $git_commit, array $payload) : void 34 | { 35 | $repository = new Model\GithubRepository( 36 | $payload['repository']['owner']['login'], 37 | $payload['repository']['name'] 38 | ); 39 | 40 | /** @var string $date */ 41 | $date = date('Y-m-d H:i:s', $payload['head']['date'] ?? date('U')); 42 | self::setRepositoryForMasterCommit($git_commit, $repository, $date); 43 | 44 | error_log('GitHub data saved for ' . $git_commit); 45 | } 46 | 47 | public static function getRepositoryForCommitAndPayload( 48 | string $git_commit_hash, 49 | array $payload 50 | ): ?Model\GithubRepository { 51 | if (!empty($payload['build']['CI_REPO_OWNER']) 52 | && !empty($payload['build']['CI_REPO_NAME']) 53 | ) { 54 | if (empty($payload['build']['CI_PR_REPO_OWNER']) 55 | && empty($payload['build']['CI_PR_REPO_NAME']) 56 | ) { 57 | $repository = new Model\GithubRepository( 58 | $payload['build']['CI_REPO_OWNER'], 59 | $payload['build']['CI_REPO_NAME'] 60 | ); 61 | 62 | if (($payload['build']['CI_BRANCH'] ?? '') === GithubApi::fetchDefaultBranch($repository)) { 63 | return $repository; 64 | } 65 | } 66 | 67 | if (!empty($payload['build']['CI_PR_REPO_OWNER']) 68 | && !empty($payload['build']['CI_PR_REPO_NAME']) 69 | && $payload['build']['CI_PR_REPO_OWNER'] === $payload['build']['CI_REPO_OWNER'] 70 | && $payload['build']['CI_PR_REPO_NAME'] === $payload['build']['CI_REPO_NAME'] 71 | ) { 72 | return new Model\GithubRepository( 73 | $payload['build']['CI_REPO_OWNER'], 74 | $payload['build']['CI_REPO_NAME'] 75 | ); 76 | } 77 | } 78 | 79 | if ($repository = self::getRepositoryForPullRequestCommit($git_commit_hash)) { 80 | return $repository; 81 | } 82 | 83 | return self::getRepositoryForMasterCommit($git_commit_hash); 84 | } 85 | 86 | private static function getRepositoryForMasterCommit(string $git_commit) : ?Model\GithubRepository 87 | { 88 | $connection = DatabaseProvider::getConnection(); 89 | 90 | $stmt = $connection->prepare( 91 | 'SELECT owner_name, repo_name 92 | FROM github_master_commits 93 | WHERE git_commit = :git_commit' 94 | ); 95 | 96 | $stmt->bindValue(':git_commit', $git_commit); 97 | 98 | $stmt->execute(); 99 | 100 | /** @var array{owner_name: string, repo_name: string}|null */ 101 | $row = $stmt->fetch(PDO::FETCH_ASSOC); 102 | 103 | if (!$row) { 104 | return null; 105 | } 106 | 107 | return new Model\GithubRepository( 108 | $row['owner_name'], 109 | $row['repo_name'] 110 | ); 111 | } 112 | 113 | private static function getRepositoryForPullRequestCommit(string $git_commit) : ?Model\GithubRepository 114 | { 115 | $pull_request = self::getPullRequestFromDatabase($git_commit); 116 | 117 | if (!$pull_request) { 118 | return null; 119 | } 120 | 121 | return $pull_request->repository; 122 | } 123 | 124 | public static function setRepositoryForMasterCommit( 125 | string $git_commit, 126 | Model\GithubRepository $repository, 127 | string $created_on 128 | ) : void { 129 | $connection = DatabaseProvider::getConnection(); 130 | 131 | $stmt = $connection->prepare( 132 | 'INSERT IGNORE INTO github_master_commits (git_commit, owner_name, repo_name, created_on) 133 | VALUES (:git_commit, :owner_name, :repo_name, :created_on)' 134 | ); 135 | 136 | $stmt->bindValue(':git_commit', $git_commit); 137 | $stmt->bindValue(':owner_name', $repository->owner_name); 138 | $stmt->bindValue(':repo_name', $repository->repo_name); 139 | $stmt->bindValue(':created_on', $created_on); 140 | 141 | $stmt->execute(); 142 | } 143 | 144 | public static function getPullRequestForCommitAndPayload( 145 | string $git_commit_hash, 146 | Model\GithubRepository $repository, 147 | array $payload 148 | ) : ?Model\Database\GithubPullRequest { 149 | $github_pull_request = self::getPullRequestFromDatabase($git_commit_hash); 150 | 151 | if (!$github_pull_request 152 | && !empty($payload['build']['CI_PR_NUMBER']) 153 | && $payload['build']['CI_PR_NUMBER'] !== "false" 154 | ) { 155 | $pr_number = (int) $payload['build']['CI_PR_NUMBER']; 156 | 157 | self::storePullRequestData( 158 | $git_commit_hash, 159 | GithubApi::fetchPullRequestData( 160 | $repository, 161 | $pr_number 162 | ) 163 | ); 164 | 165 | $github_pull_request = self::getPullRequestFromDatabase($git_commit_hash); 166 | } 167 | 168 | return $github_pull_request; 169 | } 170 | 171 | private static function getPullRequestFromDatabase(string $git_commit) : ?Model\Database\GithubPullRequest 172 | { 173 | $connection = DatabaseProvider::getConnection(); 174 | 175 | $stmt = $connection->prepare( 176 | 'SELECT `owner_name`, `repo_name`, `number`, `git_commit`, `branch`, `url` 177 | FROM `github_pull_requests` 178 | WHERE git_commit = :git_commit' 179 | ); 180 | 181 | $stmt->bindValue(':git_commit', $git_commit); 182 | 183 | $stmt->execute(); 184 | 185 | /** @var array{owner_name: string, repo_name: string, number: int, git_commit: string, branch: string, url: string}|null */ 186 | $row = $stmt->fetch(PDO::FETCH_ASSOC); 187 | 188 | if (!$row) { 189 | return null; 190 | } 191 | 192 | return Model\Database\GithubPullRequest::fromDatabaseData($row); 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /src/PhpunitData.php: -------------------------------------------------------------------------------- 1 | branch; 28 | 29 | foreach ($test_names as $test_name) { 30 | if (self::hasRegisteredTestFailureForCommit($git_commit, $test_name)) { 31 | continue; 32 | } 33 | 34 | self::registerTestFailureForCommit( 35 | $git_commit, 36 | $test_name, 37 | $repository->owner_name . '/' . $repository->repo_name, 38 | $branch 39 | ); 40 | } 41 | 42 | Sender::addGithubReview( 43 | 'phpunit', 44 | Auth::getToken($repository), 45 | $github_pull_request, 46 | self::getGithubReviewForCommitAndBranch( 47 | $git_commit, 48 | $branch, 49 | $repository->owner_name . '/' . $repository->repo_name 50 | ) 51 | ); 52 | } 53 | } 54 | 55 | private static function getGithubReviewForCommitAndBranch( 56 | string $git_commit, 57 | string $branch, 58 | string $repository 59 | ) : Model\GithubReview { 60 | $flaky_tests = []; 61 | $repeated_failure_tests = []; 62 | $first_time_failures = []; 63 | 64 | $test_failures = self::getTestFailures($git_commit, $branch, $repository); 65 | 66 | if (!$test_failures) { 67 | throw new \UnexpectedValueException( 68 | 'Could not find any test failures for ' . $git_commit . ', ' . $branch . ' and ' . $repository 69 | ); 70 | } 71 | 72 | foreach ($test_failures as $test_name) { 73 | if (self::hasFailedBeforeOnOtherBranches($test_name, $branch, $git_commit, $repository)) { 74 | $flaky_tests[] = $test_name; 75 | } elseif (self::hasFailedBeforeOnBranch($test_name, $branch, $git_commit, $repository)) { 76 | $repeated_failure_tests[] = $test_name; 77 | } else { 78 | $first_time_failures[] = $test_name; 79 | } 80 | } 81 | 82 | $message = ''; 83 | 84 | foreach ($flaky_tests as $test_name) { 85 | $message .= 'PHPUnit test `' . $test_name . '` has failed before in other branches' . PHP_EOL; 86 | } 87 | 88 | foreach ($repeated_failure_tests as $test_name) { 89 | $message .= 'PHPUnit test `' . $test_name . '` has failed before in this branch' . PHP_EOL; 90 | } 91 | 92 | foreach ($first_time_failures as $test_name) { 93 | $message .= 'PHPUnit test `' . $test_name . '` has never failed before in any branch.' . PHP_EOL; 94 | } 95 | 96 | return new Model\GithubReview($message, false); 97 | } 98 | 99 | private static function hasFailedBeforeOnOtherBranches( 100 | string $test_name, 101 | string $branch, 102 | string $git_commit, 103 | string $repository 104 | ) : bool { 105 | $connection = DatabaseProvider::getConnection(); 106 | 107 | $stmt = $connection->prepare( 108 | 'SELECT COUNT(*) 109 | FROM test_failures 110 | WHERE test_name = :test_name 111 | AND branch != :branch 112 | AND git_commit != :git_commit 113 | AND repository = :repository' 114 | ); 115 | 116 | $stmt->bindValue(':test_name', $test_name); 117 | $stmt->bindValue(':branch', $branch); 118 | $stmt->bindValue(':git_commit', $git_commit); 119 | $stmt->bindValue(':repository', $repository); 120 | 121 | $stmt->execute(); 122 | 123 | return $stmt->fetchColumn() > 0; 124 | } 125 | 126 | private static function hasFailedBeforeOnBranch( 127 | string $test_name, 128 | string $branch, 129 | string $git_commit, 130 | string $repository 131 | ) : bool { 132 | $connection = DatabaseProvider::getConnection(); 133 | 134 | $stmt = $connection->prepare( 135 | 'SELECT COUNT(*) 136 | FROM test_failures 137 | WHERE test_name = :test_name 138 | AND branch = :branch 139 | AND git_commit != :git_commit 140 | AND repository = :repository' 141 | ); 142 | 143 | $stmt->bindValue(':test_name', $test_name); 144 | $stmt->bindValue(':branch', $branch); 145 | $stmt->bindValue(':git_commit', $git_commit); 146 | $stmt->bindValue(':repository', $repository); 147 | 148 | $stmt->execute(); 149 | 150 | return $stmt->fetchColumn() > 0; 151 | } 152 | 153 | private static function getTestFailures(string $git_commit, string $branch, string $repository) : array 154 | { 155 | $connection = DatabaseProvider::getConnection(); 156 | 157 | $stmt = $connection->prepare( 158 | 'SELECT test_name 159 | FROM test_failures 160 | WHERE git_commit = :git_commit 161 | AND branch = :branch 162 | AND repository = :repository' 163 | ); 164 | 165 | $stmt->bindValue(':git_commit', $git_commit); 166 | $stmt->bindValue(':branch', $branch); 167 | $stmt->bindValue(':repository', $repository); 168 | 169 | $stmt->execute(); 170 | 171 | return $stmt->fetchAll(PDO::FETCH_COLUMN, 0); 172 | } 173 | 174 | private static function hasRegisteredTestFailureForCommit(string $git_commit, string $test_name) : bool 175 | { 176 | $connection = DatabaseProvider::getConnection(); 177 | 178 | $stmt = $connection->prepare( 179 | 'SELECT COUNT(*) 180 | FROM test_failures 181 | WHERE git_commit = :git_commit 182 | AND test_name = :test_name' 183 | ); 184 | 185 | $stmt->bindValue(':git_commit', $git_commit); 186 | $stmt->bindValue(':test_name', $test_name); 187 | 188 | $stmt->execute(); 189 | 190 | return $stmt->fetchColumn() > 0; 191 | } 192 | 193 | private static function registerTestFailureForCommit( 194 | string $git_commit, 195 | string $test_name, 196 | ?string $repository, 197 | string $branch 198 | ) : void { 199 | $connection = DatabaseProvider::getConnection(); 200 | 201 | error_log('Registering test failure for ' . $test_name . PHP_EOL); 202 | 203 | $stmt = $connection->prepare(' 204 | INSERT IGNORE into test_failures (repository, git_commit, branch, test_name) 205 | VALUES (:repository, :git_commit, :branch, :test_name)'); 206 | 207 | $stmt->bindValue(':git_commit', $git_commit); 208 | $stmt->bindValue(':branch', $branch); 209 | $stmt->bindValue(':repository', $repository); 210 | $stmt->bindValue(':test_name', $test_name); 211 | 212 | $stmt->execute(); 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /src/Sender.php: -------------------------------------------------------------------------------- 1 | gh_enterprise_url); 18 | $client->authenticate($github_token, null, \Github\Client::AUTH_ACCESS_TOKEN); 19 | 20 | $repository = $pull_request->repository->owner_name . '/' . $pull_request->repository->repo_name; 21 | 22 | try { 23 | $diff_string = $client 24 | ->api('pull_request') 25 | ->configure('diff', 'v3') 26 | ->show( 27 | $pull_request->repository->owner_name, 28 | $pull_request->repository->repo_name, 29 | $pull_request->number 30 | ); 31 | } catch (\Github\Exception\RuntimeException $e) { 32 | throw new \RuntimeException( 33 | 'Could not fetch pull request diff for ' . $pull_request->number . ' on ' . $repository 34 | ); 35 | } 36 | 37 | if (!is_string($diff_string)) { 38 | throw new \UnexpectedValueException('$diff_string should be a string'); 39 | } 40 | 41 | return $diff_string; 42 | } 43 | 44 | public static function addGithubReview( 45 | string $review_type, 46 | string $github_token, 47 | Model\Database\GithubPullRequest $pull_request, 48 | Model\GithubReview $github_review 49 | ) : void { 50 | $config = Config::getInstance(); 51 | 52 | $client = new \Github\Client(null, null, $config->gh_enterprise_url); 53 | $client->authenticate($github_token, null, \Github\Client::AUTH_ACCESS_TOKEN); 54 | 55 | $review_id = self::getGithubReviewIdForPullRequest($pull_request->url, $review_type); 56 | $comment_id = self::getGithubCommentIdForPullRequest($pull_request->url, $review_type); 57 | 58 | if ($review_id) { 59 | // deletes review comments 60 | self::deleteCommentsForReview($client, $pull_request, $review_id); 61 | } 62 | 63 | if ($comment_id) { 64 | // deletes the review itself 65 | self::deleteComment($client, $pull_request, $comment_id); 66 | } 67 | 68 | if ($github_review->file_comments) { 69 | error_log('Adding Github file comments on ' . $pull_request->url); 70 | 71 | self::addGithubReviewComments($client, $pull_request, $review_type, $github_review->file_comments); 72 | } 73 | 74 | if ($review_id || $comment_id || !$github_review->checks_passed) { 75 | error_log('Adding Github Review on ' . $pull_request->url); 76 | 77 | self::addGithubReviewComment($client, $pull_request, $review_type, $github_review->message); 78 | } 79 | } 80 | 81 | private static function deleteCommentsForReview( 82 | \Github\Client $client, 83 | Model\Database\GithubPullRequest $pull_request, 84 | int $review_id 85 | ) : void { 86 | $repository = $pull_request->repository; 87 | $repository_slug = $repository->owner_name . '/' . $repository->repo_name; 88 | 89 | try { 90 | $comments = $client 91 | ->api('pull_request') 92 | ->reviews() 93 | ->comments( 94 | $repository->owner_name, 95 | $repository->repo_name, 96 | $pull_request->number, 97 | $review_id 98 | ); 99 | } catch (\Github\Exception\RuntimeException $e) { 100 | throw new \RuntimeException( 101 | 'Could not fetch comments for review ' 102 | . $review_id . ' for pull request ' 103 | . $pull_request->number . ' on ' . $repository_slug 104 | ); 105 | } 106 | 107 | if (is_array($comments)) { 108 | foreach ($comments as $comment) { 109 | try { 110 | $client 111 | ->api('pull_request') 112 | ->comments() 113 | ->remove( 114 | $repository->owner_name, 115 | $repository->repo_name, 116 | $comment['id'] 117 | ); 118 | } catch (\Github\Exception\RuntimeException $e) { 119 | error_log( 120 | 'Could not remove PR comment (via PR API) ' . $comment['id'] . ' on ' . $repository_slug 121 | ); 122 | } 123 | } 124 | } 125 | } 126 | 127 | private static function deleteComment( 128 | \Github\Client $client, 129 | Model\Database\GithubPullRequest $pull_request, 130 | int $comment_id 131 | ) : void { 132 | $repository = $pull_request->repository; 133 | $repository_slug = $repository->owner_name . '/' . $repository->repo_name; 134 | 135 | try { 136 | $client 137 | ->api('issue') 138 | ->comments() 139 | ->remove( 140 | $repository->owner_name, 141 | $repository->repo_name, 142 | $comment_id 143 | ); 144 | } catch (\Github\Exception\RuntimeException $e) { 145 | error_log( 146 | 'Could not remove PR comment (via issues API) ' . $comment_id . ' on ' . $repository_slug 147 | ); 148 | } 149 | } 150 | 151 | private static function addGithubReviewComments( 152 | \Github\Client $client, 153 | Model\Database\GithubPullRequest $pull_request, 154 | string $tool, 155 | array $file_comments 156 | ) : void { 157 | $repository = $pull_request->repository; 158 | $repository_slug = $repository->owner_name . '/' . $repository->repo_name; 159 | 160 | try { 161 | $review = $client 162 | ->api('pull_request') 163 | ->reviews() 164 | ->create( 165 | $repository->owner_name, 166 | $repository->repo_name, 167 | $pull_request->number, 168 | [ 169 | 'commit_id' => $pull_request->head_commit, 170 | 'body' => '', 171 | 'comments' => $file_comments, 172 | 'event' => 'REQUEST_CHANGES', 173 | ] 174 | ); 175 | } catch (\Github\Exception\RuntimeException $e) { 176 | throw new \RuntimeException( 177 | 'Could not create PR review for ' . $pull_request->number . ' on ' . $repository_slug 178 | ); 179 | } 180 | 181 | self::storeGithubReviewForPullRequest($pull_request->url, $tool, $review['id']); 182 | } 183 | 184 | private static function addGithubReviewComment( 185 | \Github\Client $client, 186 | Model\Database\GithubPullRequest $pull_request, 187 | string $tool, 188 | string $message_body 189 | ) : void { 190 | $repository = $pull_request->repository; 191 | $repository_slug = $repository->owner_name . '/' . $repository->repo_name; 192 | 193 | try { 194 | $comment = $client 195 | ->api('issue') 196 | ->comments() 197 | ->create( 198 | $repository->owner_name, 199 | $repository->repo_name, 200 | $pull_request->number, 201 | [ 202 | 'body' => $message_body, 203 | ] 204 | ); 205 | } catch (\Github\Exception\RuntimeException $e) { 206 | throw new \RuntimeException( 207 | 'Could not add comment for ' . $pull_request->number . ' on ' . $repository_slug 208 | ); 209 | } 210 | 211 | self::storeGithubCommentForPullRequest($pull_request->url, $tool, $comment['id']); 212 | } 213 | 214 | private static function storeGithubReviewForPullRequest( 215 | string $github_pr_url, 216 | string $tool, 217 | int $github_review_id 218 | ) : void { 219 | $connection = DatabaseProvider::getConnection(); 220 | 221 | $stmt = $connection->prepare( 222 | 'DELETE FROM github_pr_reviews where github_pr_url = :github_pr_url and tool = :tool' 223 | ); 224 | 225 | $stmt->bindValue(':github_pr_url', $github_pr_url); 226 | $stmt->bindValue(':tool', $tool); 227 | 228 | $stmt->execute(); 229 | 230 | $stmt = $connection->prepare( 231 | 'INSERT INTO github_pr_reviews (github_pr_url, tool, github_review_id) 232 | VALUES (:github_pr_url, :tool, :github_review_id)' 233 | ); 234 | 235 | $stmt->bindValue(':github_pr_url', $github_pr_url); 236 | $stmt->bindValue(':tool', $tool); 237 | $stmt->bindValue(':github_review_id', $github_review_id); 238 | 239 | $stmt->execute(); 240 | } 241 | 242 | private static function storeGithubCommentForPullRequest( 243 | string $github_pr_url, 244 | string $tool, 245 | int $github_comment_id 246 | ) : void { 247 | $connection = DatabaseProvider::getConnection(); 248 | 249 | $stmt = $connection->prepare( 250 | 'INSERT INTO github_pr_comments (github_pr_url, tool, github_comment_id) 251 | VALUES (:github_pr_url, :tool, :github_comment_id)' 252 | ); 253 | 254 | $stmt->bindValue(':github_pr_url', $github_pr_url); 255 | $stmt->bindValue(':tool', $tool); 256 | $stmt->bindValue(':github_comment_id', $github_comment_id); 257 | 258 | $stmt->execute(); 259 | } 260 | 261 | private static function getGithubReviewIdForPullRequest( 262 | string $github_pr_url, 263 | string $tool 264 | ) : ?int { 265 | $connection = DatabaseProvider::getConnection(); 266 | 267 | $stmt = $connection->prepare( 268 | 'SELECT github_review_id 269 | FROM github_pr_reviews 270 | WHERE github_pr_url = :github_pr_url 271 | AND tool = :tool' 272 | ); 273 | 274 | $stmt->bindValue(':github_pr_url', $github_pr_url); 275 | $stmt->bindValue(':tool', $tool); 276 | 277 | $stmt->execute(); 278 | 279 | $id = $stmt->fetchColumn(); 280 | 281 | return $id ? (int) $id : null; 282 | } 283 | 284 | private static function getGithubCommentIdForPullRequest( 285 | string $github_pr_url, 286 | string $tool 287 | ) : ?int { 288 | $connection = DatabaseProvider::getConnection(); 289 | 290 | $stmt = $connection->prepare( 291 | 'SELECT github_comment_id 292 | FROM github_pr_comments 293 | WHERE github_pr_url = :github_pr_url 294 | AND tool = :tool' 295 | ); 296 | 297 | $stmt->bindValue(':github_pr_url', $github_pr_url); 298 | $stmt->bindValue(':tool', $tool); 299 | 300 | $stmt->execute(); 301 | 302 | $id = $stmt->fetchColumn(); 303 | 304 | return $id ? (int) $id : null; 305 | } 306 | } 307 | -------------------------------------------------------------------------------- /src/GithubApi.php: -------------------------------------------------------------------------------- 1 | repository()->show($repository->owner_name, $repository->repo_name); 30 | /** @var string $default_branch */ 31 | $default_branch = $response['default_branch'] ?? static::DEFAULT_GITHUB_BRANCH; 32 | 33 | return $default_branch; 34 | } 35 | 36 | public static function fetchPullRequestData( 37 | Model\GithubRepository $repository, 38 | int $pr_number 39 | ): array { 40 | $client = static::createAuthenticatedClient($repository); 41 | 42 | error_log( 43 | 'Fetching pull request data for ' 44 | . $repository->owner_name 45 | . '/' . $repository->repo_name 46 | . '/' . $pr_number 47 | ); 48 | 49 | $pr = $client 50 | ->api('pull_request') 51 | ->show( 52 | $repository->owner_name, 53 | $repository->repo_name, 54 | $pr_number 55 | ); 56 | 57 | return [ 58 | 'pull_request' => $pr, 59 | ]; 60 | } 61 | 62 | private static function createAuthenticatedClient(Model\GithubRepository $repository): Client 63 | { 64 | $config = Config::getInstance(); 65 | $github_token = Auth::getToken($repository); 66 | 67 | $client = new Client(null, null, $config->gh_enterprise_url); 68 | $client->authenticate($github_token, null, \Github\Client::AUTH_ACCESS_TOKEN); 69 | 70 | return $client; 71 | } 72 | 73 | public static function fetchPsalmIssuesData(?string $after) : array 74 | { 75 | $query = 'query($afterCursor: String) { 76 | repository(owner: "vimeo", name: "psalm") { 77 | issues(states: OPEN, first: 30, after: $afterCursor) { 78 | pageInfo { 79 | startCursor 80 | hasNextPage 81 | endCursor 82 | } 83 | nodes { 84 | number, 85 | bodyText, 86 | comments(first: 3) { 87 | nodes { 88 | body, 89 | author { 90 | login 91 | } 92 | } 93 | } 94 | } 95 | } 96 | } 97 | }'; 98 | 99 | $client = static::createAuthenticatedClient(new Model\GithubRepository('vimeo', 'psalm')); 100 | 101 | $different_issues = []; 102 | 103 | $data = $client->api('graphql')->execute($query, ['afterCursor' => $after])['data']; 104 | 105 | $db_config = json_decode(file_get_contents(dirname(__DIR__) . '/config.json'), true)['mysql_psalm_dev']; 106 | 107 | try { 108 | $pdo = new \PDO($db_config['dsn'], $db_config['user'], $db_config['password']); 109 | } catch (\PDOException $e) { 110 | die('Connection to database failed'); 111 | } 112 | 113 | $updates = []; 114 | 115 | foreach ($data['repository']['issues']['nodes'] as $issue) { 116 | foreach ($issue['comments']['nodes'] as $comment) { 117 | if ($comment['author']['login'] === 'psalm-github-bot' 118 | && strpos($comment['body'], 'I found these snippets:') !== false 119 | ) { 120 | $select_sql = 'SELECT COUNT(*) FROM `codes` WHERE `github_issue` = :github_issue'; 121 | $stmt = $pdo->prepare($select_sql); 122 | $stmt->execute(['github_issue' => $issue['number']]); 123 | 124 | if ($stmt->fetchColumn() > 0) { 125 | $select_sql = 'SELECT COUNT(*) FROM `codes` WHERE `github_issue` = :github_issue AND `posted_cache` != `recent_cache`'; 126 | $stmt = $pdo->prepare($select_sql); 127 | $stmt->execute(['github_issue' => $issue['number']]); 128 | if ($stmt->fetchColumn() == 0) { 129 | continue 2; 130 | } 131 | } 132 | 133 | $body = $comment['body']; 134 | 135 | $lines = array_values( 136 | array_filter( 137 | explode("\n", $body), 138 | function ($line) { 139 | return $line !== 'I found these snippets:' 140 | && $line !== '
' 141 | && $line !== '
'; 142 | } 143 | ) 144 | ); 145 | 146 | $link = null; 147 | 148 | $in_php = false; 149 | $in_results = false; 150 | 151 | $posted_result = ''; 152 | 153 | $posted_results = []; 154 | 155 | $old_commit = ''; 156 | 157 | foreach ($lines as $line) { 158 | if (strpos($line, '') === 0) { 159 | $link = substr($line, 9, -10); 160 | continue; 161 | } 162 | 163 | if (strpos($line, 'Psalm output') === 0) { 164 | $old_commit_pos = strpos($line, 'Psalm output (using commit '); 165 | 166 | if ($old_commit_pos !== false) { 167 | $old_commit = substr(trim($line), 27, 7); 168 | } 169 | continue; 170 | } 171 | 172 | if ($line === '```php') { 173 | $in_php = true; 174 | continue; 175 | } 176 | 177 | if ($line === '```') { 178 | if ($in_php) { 179 | $in_php = false; 180 | continue; 181 | } 182 | 183 | if ($in_results) { 184 | $in_results = false; 185 | 186 | if ($link === null) { 187 | throw new \UnexpectedValueException('No link'); 188 | } 189 | 190 | $posted_results[$link] = trim($posted_result); 191 | 192 | continue; 193 | } 194 | 195 | $in_results = true; 196 | $posted_result = ''; 197 | continue; 198 | } 199 | 200 | if ($in_results) { 201 | $posted_result .= $line . "\n"; 202 | } 203 | } 204 | 205 | foreach ($posted_results as $link => $posted_result) { 206 | $recent_cache_commit = ''; 207 | $current_result = self::formatSnippetResult( 208 | json_decode( 209 | file_get_contents($link . '/results'), 210 | true 211 | ) ?: [], 212 | $recent_cache_commit 213 | ); 214 | 215 | $current_result_normalised = str_ireplace( 216 | [ 217 | 'class or interface', 218 | 'class, interface or enum named', 219 | ' or the value is not used', 220 | 'Variable $', 221 | '"', 222 | 'empty', 223 | 'InvalidScalarArgument', 224 | '@psalm-immutable', 225 | ], 226 | ['', '', '', '$', '\'', 'never', 'InvalidArgument', 'immutable'], 227 | $current_result 228 | ); 229 | 230 | $posted_result_normalised = str_ireplace( 231 | [ 232 | 'class or interface', 233 | 'class, interface or enum named', 234 | ' or the value is not used', 235 | 'and in any private', 236 | 'Variable $', 237 | '"', 238 | 'an possibly', 239 | 'and in any methods', 240 | 'empty', 241 | 'InvalidScalarArgument', 242 | '@psalm-immutable', 243 | ], 244 | ['', '', '', 'or in any private', '$', '\'', 'a possibly', 'or in any methods', 'never', 'InvalidArgument', 'immutable'], 245 | $posted_result 246 | ); 247 | 248 | $current_result_normalised = preg_replace( 249 | '/string\(([A-Za-z0-9_]+)\)/', 250 | '\'$1\'', 251 | $current_result_normalised 252 | ); 253 | $current_result_normalised = preg_replace( 254 | '/int\(([A-Za-z0-9]+)\)/', 255 | '$1', 256 | $current_result_normalised 257 | ); 258 | 259 | $posted_result_normalised = preg_replace( 260 | '/string\(([A-Za-z0-9_]+)\)/', 261 | '\'$1\'', 262 | $posted_result_normalised 263 | ); 264 | $posted_result_normalised = preg_replace( 265 | '/int\(([A-Za-z0-9]+)\)/', 266 | '$1', 267 | $posted_result_normalised 268 | ); 269 | 270 | $posted_commit = $old_commit; 271 | 272 | if ($current_result_normalised !== $posted_result_normalised) { 273 | $different_issues[$issue['number']][$link] = [$current_result, $posted_result]; 274 | } else { 275 | $posted_result = $current_result; 276 | $posted_commit = $recent_cache_commit; 277 | } 278 | 279 | $link_parts = \explode("/", $link); 280 | $hash = \end($link_parts); 281 | $updates[] = [ 282 | 'hash' => $hash, 283 | 'posted_cache' => $posted_result, 284 | 'posted_cache_commit' => $posted_commit ?: null, 285 | 'recent_cache' => $current_result, 286 | 'recent_cache_commit' => $recent_cache_commit, 287 | 'github_issue' => $issue['number'], 288 | ]; 289 | } 290 | 291 | continue; 292 | } 293 | } 294 | } 295 | 296 | foreach ($updates as $update) { 297 | $insert_sql = 'UPDATE `codes` 298 | SET `posted_cache` = :posted_cache, 299 | `posted_cache_commit` = :posted_cache_commit, 300 | `recent_cache` = :recent_cache, 301 | `recent_cache_commit` = :recent_cache_commit, 302 | `github_issue` = :github_issue 303 | WHERE `hash` = :hash 304 | LIMIT 1'; 305 | $stmt = $pdo->prepare($insert_sql); 306 | $stmt->execute($update); 307 | } 308 | 309 | return [$different_issues, $data['repository']['issues']['pageInfo']['endCursor']]; 310 | } 311 | 312 | private static function formatSnippetResult(array $data, string &$commit): string 313 | { 314 | $version = $data['version']; 315 | 316 | $commit = substr($version, 11, 7); 317 | 318 | if ($data['results'] === null) { 319 | return ''; 320 | } 321 | 322 | if ($data['results'] === []) { 323 | return 'No issues!'; 324 | } 325 | 326 | return implode( 327 | "\n\n", 328 | array_map( 329 | function (array $issue) { 330 | return strtoupper($issue['severity']) 331 | . ': ' . $issue['type'] 332 | . ' - ' .$issue['line_from'] 333 | . ':' . $issue['column_from'] 334 | . ' - ' . $issue['message']; 335 | }, 336 | $data['results'] 337 | ) 338 | ); 339 | } 340 | } 341 | -------------------------------------------------------------------------------- /tests/test.diff: -------------------------------------------------------------------------------- 1 | diff --git a/src/Psalm/Internal/Analyzer/ClassAnalyzer.php b/src/Psalm/Internal/Analyzer/ClassAnalyzer.php 2 | index 945570f40..88ac21ad7 100644 3 | --- a/src/Psalm/Internal/Analyzer/ClassAnalyzer.php 4 | +++ b/src/Psalm/Internal/Analyzer/ClassAnalyzer.php 5 | @@ -1496,9 +1496,7 @@ private function checkTemplateParams( 6 | if (!$template_type[0]->isMixed() 7 | && isset($storage->template_type_extends[strtolower($parent_storage->name)][$i]) 8 | ) { 9 | - $extended_type = new Type\Union([ 10 | - $storage->template_type_extends[strtolower($parent_storage->name)][$i] 11 | - ]); 12 | + $extended_type = $storage->template_type_extends[strtolower($parent_storage->name)][$i]; 13 | 14 | if (!TypeAnalyzer::isContainedBy($codebase, $extended_type, $template_type[0])) { 15 | if (IssueBuffer::accepts( 16 | diff --git a/src/Psalm/Internal/Analyzer/MethodAnalyzer.php b/src/Psalm/Internal/Analyzer/MethodAnalyzer.php 17 | index 673259c44..066ad3f7c 100644 18 | --- a/src/Psalm/Internal/Analyzer/MethodAnalyzer.php 19 | +++ b/src/Psalm/Internal/Analyzer/MethodAnalyzer.php 20 | @@ -613,9 +613,9 @@ public static function compareMethods( 21 | 22 | $template_types = []; 23 | 24 | - foreach ($map as $key => $atomic_type) { 25 | + foreach ($map as $key => $type) { 26 | if (is_string($key)) { 27 | - $template_types[$key] = [new Type\Union([$atomic_type]), $guide_classlike_storage->name]; 28 | + $template_types[$key] = [$type, $guide_classlike_storage->name]; 29 | } 30 | } 31 | 32 | @@ -638,10 +638,10 @@ public static function compareMethods( 33 | 34 | $template_types = []; 35 | 36 | - foreach ($map as $key => $atomic_type) { 37 | + foreach ($map as $key => $type) { 38 | if (is_string($key)) { 39 | $template_types[$key] = [ 40 | - new Type\Union([$atomic_type]), 41 | + $type, 42 | $implementer_method_storage->defining_fqcln 43 | ]; 44 | } 45 | @@ -771,9 +771,9 @@ public static function compareMethods( 46 | 47 | $template_types = []; 48 | 49 | - foreach ($map as $key => $atomic_type) { 50 | + foreach ($map as $key => $type) { 51 | if (is_string($key)) { 52 | - $template_types[$key] = [new Type\Union([$atomic_type]), $guide_classlike_storage->name]; 53 | + $template_types[$key] = [$type, $guide_classlike_storage->name]; 54 | } 55 | } 56 | 57 | diff --git a/src/Psalm/Internal/Analyzer/Statements/Block/ForeachAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Block/ForeachAnalyzer.php 58 | index 55b819d10..e86c777d6 100644 59 | --- a/src/Psalm/Internal/Analyzer/Statements/Block/ForeachAnalyzer.php 60 | +++ b/src/Psalm/Internal/Analyzer/Statements/Block/ForeachAnalyzer.php 61 | @@ -786,7 +786,7 @@ function (array $arr) : Type\Union { 62 | 63 | /** 64 | * @param string $template_name 65 | - * @param array> $template_type_extends 66 | + * @param array> $template_type_extends 67 | * @param array $class_template_types 68 | * @param array $calling_type_params 69 | * @return Type\Union|null 70 | @@ -814,19 +814,47 @@ private static function getExtendedType( 71 | if (isset($template_type_extends[$template_class_lc][$template_name])) { 72 | $extended_type = $template_type_extends[$template_class_lc][$template_name]; 73 | 74 | - if (!$extended_type instanceof Type\Atomic\TTemplateParam) { 75 | - return new Type\Union([$extended_type]); 76 | + $return_type = null; 77 | + 78 | + foreach ($extended_type->getTypes() as $extended_atomic_type) { 79 | + if (!$extended_atomic_type instanceof Type\Atomic\TTemplateParam) { 80 | + if (!$return_type) { 81 | + $return_type = $extended_type; 82 | + } else { 83 | + $return_type = Type::combineUnionTypes( 84 | + $return_type, 85 | + $extended_type 86 | + ); 87 | + } 88 | + 89 | + continue; 90 | + } 91 | + 92 | + if ($extended_atomic_type->defining_class) { 93 | + $candidate_type = self::getExtendedType( 94 | + $extended_atomic_type->param_name, 95 | + strtolower($extended_atomic_type->defining_class), 96 | + $calling_class_lc, 97 | + $template_type_extends, 98 | + $class_template_types, 99 | + $calling_type_params 100 | + ); 101 | + 102 | + if ($candidate_type) { 103 | + if (!$return_type) { 104 | + $return_type = $candidate_type; 105 | + } else { 106 | + $return_type = Type::combineUnionTypes( 107 | + $return_type, 108 | + $candidate_type 109 | + ); 110 | + } 111 | + } 112 | + } 113 | } 114 | 115 | - if ($extended_type->defining_class) { 116 | - return self::getExtendedType( 117 | - $extended_type->param_name, 118 | - strtolower($extended_type->defining_class), 119 | - $calling_class_lc, 120 | - $template_type_extends, 121 | - $class_template_types, 122 | - $calling_type_params 123 | - ); 124 | + if ($return_type) { 125 | + return $return_type; 126 | } 127 | } 128 | 129 | diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/MethodCallAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/MethodCallAnalyzer.php 130 | index c3f4fff82..98ec8de6e 100644 131 | --- a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/MethodCallAnalyzer.php 132 | +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/MethodCallAnalyzer.php 133 | @@ -1183,52 +1183,75 @@ public static function getClassTemplateParams( 134 | if ($class_storage !== $calling_class_storage 135 | && isset($e[strtolower($class_storage->name)][$type_name]) 136 | ) { 137 | - $type_extends = $e[strtolower($class_storage->name)][$type_name]; 138 | - 139 | - if ($type_extends instanceof Type\Atomic\TTemplateParam) { 140 | - if (isset($calling_class_storage->template_types[$type_extends->param_name])) { 141 | - $mapped_offset = array_search( 142 | - $type_extends->param_name, 143 | - array_keys($calling_class_storage->template_types) 144 | - ); 145 | - 146 | - if (isset($lhs_type_part->type_params[(int) $mapped_offset])) { 147 | - $class_template_params[$type_name] = [ 148 | - $lhs_type_part->type_params[(int) $mapped_offset], 149 | - $class_storage->name, 150 | - 0, 151 | - ]; 152 | - } 153 | - } elseif ($type_extends->defining_class 154 | - && isset( 155 | - $calling_class_storage 156 | + $input_type_extends = $e[strtolower($class_storage->name)][$type_name]; 157 | + 158 | + $output_type_extends = null; 159 | + 160 | + foreach ($input_type_extends->getTypes() as $type_extends_atomic) { 161 | + if ($type_extends_atomic instanceof Type\Atomic\TTemplateParam) { 162 | + if (isset($calling_class_storage->template_types[$type_extends_atomic->param_name])) { 163 | + $mapped_offset = array_search( 164 | + $type_extends_atomic->param_name, 165 | + array_keys($calling_class_storage->template_types) 166 | + ); 167 | + 168 | + if (isset($lhs_type_part->type_params[(int) $mapped_offset])) { 169 | + $candidate_type = $lhs_type_part->type_params[(int) $mapped_offset]; 170 | + 171 | + if (!$output_type_extends) { 172 | + $output_type_extends = $candidate_type; 173 | + } else { 174 | + $output_type_extends = Type::combineUnionTypes( 175 | + $candidate_type, 176 | + $output_type_extends 177 | + ); 178 | + } 179 | + } 180 | + } elseif ($type_extends_atomic->defining_class 181 | + && isset( 182 | + $calling_class_storage 183 | + ->template_type_extends 184 | + [strtolower($type_extends_atomic->defining_class)] 185 | + [$type_extends_atomic->param_name] 186 | + ) 187 | + ) { 188 | + $mapped_offset = array_search( 189 | + $type_extends_atomic->param_name, 190 | + array_keys($calling_class_storage 191 | ->template_type_extends 192 | - [strtolower($type_extends->defining_class)] 193 | - [$type_extends->param_name] 194 | - ) 195 | - ) { 196 | - $mapped_offset = array_search( 197 | - $type_extends->param_name, 198 | - array_keys($calling_class_storage 199 | - ->template_type_extends 200 | - [strtolower($type_extends->defining_class)]) 201 | - ); 202 | - 203 | - if (isset($lhs_type_part->type_params[(int) $mapped_offset])) { 204 | - $class_template_params[$type_name] = [ 205 | - $lhs_type_part->type_params[(int) $mapped_offset], 206 | - $class_storage->name, 207 | - 0, 208 | - ]; 209 | + [strtolower($type_extends_atomic->defining_class)]) 210 | + ); 211 | + 212 | + if (isset($lhs_type_part->type_params[(int) $mapped_offset])) { 213 | + $candidate_type = $lhs_type_part->type_params[(int) $mapped_offset]; 214 | + 215 | + if (!$output_type_extends) { 216 | + $output_type_extends = $candidate_type; 217 | + } else { 218 | + $output_type_extends = Type::combineUnionTypes( 219 | + $candidate_type, 220 | + $output_type_extends 221 | + ); 222 | + } 223 | + } 224 | + } 225 | + } else { 226 | + if (!$output_type_extends) { 227 | + $output_type_extends = new Type\Union([$type_extends_atomic]); 228 | + } else { 229 | + $output_type_extends = Type::combineUnionTypes( 230 | + new Type\Union([$type_extends_atomic]), 231 | + $output_type_extends 232 | + ); 233 | } 234 | } 235 | - } else { 236 | - $class_template_params[$type_name] = [ 237 | - new Type\Union([$type_extends]), 238 | - $class_storage->name, 239 | - 0, 240 | - ]; 241 | } 242 | + 243 | + $class_template_params[$type_name] = [ 244 | + $output_type_extends ?: Type::getMixed(), 245 | + $class_storage->name, 246 | + 0, 247 | + ]; 248 | } 249 | 250 | if (!isset($class_template_params[$type_name])) { 251 | @@ -1242,20 +1265,37 @@ public static function getClassTemplateParams( 252 | if ($class_storage !== $calling_class_storage 253 | && isset($e[strtolower($class_storage->name)][$type_name]) 254 | ) { 255 | - $type_extends = $e[strtolower($class_storage->name)][$type_name]; 256 | - if (!$type_extends instanceof Type\Atomic\TTemplateParam) { 257 | - $class_template_params[$type_name] = [ 258 | - new Type\Union([$type_extends]), 259 | - $class_storage->name, 260 | - 0, 261 | - ]; 262 | - } else { 263 | - $class_template_params[$type_name] = [ 264 | - $type_extends->as, 265 | - $class_storage->name, 266 | - 0, 267 | - ]; 268 | + $input_type_extends = $e[strtolower($class_storage->name)][$type_name]; 269 | + 270 | + $output_type_extends = null; 271 | + 272 | + foreach ($input_type_extends->getTypes() as $type_extends_atomic) { 273 | + if ($type_extends_atomic instanceof Type\Atomic\TTemplateParam) { 274 | + if (!$output_type_extends) { 275 | + $output_type_extends = $type_extends_atomic->as; 276 | + } else { 277 | + $output_type_extends = Type::combineUnionTypes( 278 | + $type_extends_atomic->as, 279 | + $output_type_extends 280 | + ); 281 | + } 282 | + } else { 283 | + if (!$output_type_extends) { 284 | + $output_type_extends = new Type\Union([$type_extends_atomic]); 285 | + } else { 286 | + $output_type_extends = Type::combineUnionTypes( 287 | + new Type\Union([$type_extends_atomic]), 288 | + $output_type_extends 289 | + ); 290 | + } 291 | + } 292 | } 293 | + 294 | + $class_template_params[$type_name] = [ 295 | + $output_type_extends ?: Type::getMixed(), 296 | + $class_storage->name, 297 | + 0, 298 | + ]; 299 | } 300 | 301 | if ($lhs_var_id !== '$this') { 302 | diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/NewAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/NewAnalyzer.php 303 | index 09d85be16..5c4f68b75 100644 304 | --- a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/NewAnalyzer.php 305 | +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/NewAnalyzer.php 306 | @@ -411,7 +411,7 @@ function (array $i) : Type\Union { 307 | 308 | /** 309 | * @param string $template_name 310 | - * @param array> $template_type_extends 311 | + * @param array> $template_type_extends 312 | * @param array $found_generic_params 313 | * @return array{Type\Union, ?string} 314 | */ 315 | @@ -426,15 +426,17 @@ private static function getGenericParamForOffset( 316 | 317 | foreach ($template_type_extends as $type_map) { 318 | foreach ($type_map as $extended_template_name => $extended_type) { 319 | - if (is_string($extended_template_name) 320 | - && $extended_type instanceof Type\Atomic\TTemplateParam 321 | - && $extended_type->param_name === $template_name 322 | - ) { 323 | - return self::getGenericParamForOffset( 324 | - $extended_template_name, 325 | - $template_type_extends, 326 | - $found_generic_params 327 | - ); 328 | + foreach ($extended_type->getTypes() as $extended_atomic_type) { 329 | + if (is_string($extended_template_name) 330 | + && $extended_atomic_type instanceof Type\Atomic\TTemplateParam 331 | + && $extended_atomic_type->param_name === $template_name 332 | + ) { 333 | + return self::getGenericParamForOffset( 334 | + $extended_template_name, 335 | + $template_type_extends, 336 | + $found_generic_params 337 | + ); 338 | + } 339 | } 340 | } 341 | } 342 | diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/CallAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/CallAnalyzer.php 343 | index 5a105f7e5..0c93a521b 100644 344 | --- a/src/Psalm/Internal/Analyzer/Statements/Expression/CallAnalyzer.php 345 | +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/CallAnalyzer.php 346 | @@ -1553,22 +1553,38 @@ private static function getTemplateTypesForFunction( 347 | if (is_string($template_name) 348 | && $class_name_lc === strtolower($class_storage->name) 349 | ) { 350 | - if ($type instanceof Type\Atomic\TTemplateParam 351 | - && $type->defining_class 352 | - && isset( 353 | - $calling_class_storage 354 | + $output_type = null; 355 | + 356 | + foreach ($type->getTypes() as $atomic_type) { 357 | + if ($atomic_type instanceof Type\Atomic\TTemplateParam 358 | + && $atomic_type->defining_class 359 | + && isset( 360 | + $calling_class_storage 361 | + ->template_type_extends 362 | + [strtolower($atomic_type->defining_class)] 363 | + [$atomic_type->param_name] 364 | + ) 365 | + ) { 366 | + $output_type_candidate = $calling_class_storage 367 | ->template_type_extends 368 | - [strtolower($type->defining_class)] 369 | - [$type->param_name] 370 | - ) 371 | - ) { 372 | - $type = $calling_class_storage 373 | - ->template_type_extends 374 | - [strtolower($type->defining_class)] 375 | - [$type->param_name]; 376 | + [strtolower($atomic_type->defining_class)] 377 | + [$atomic_type->param_name]; 378 | + } else { 379 | + $output_type_candidate = new Type\Union([$atomic_type]); 380 | + } 381 | + 382 | + if (!$output_type) { 383 | + $output_type = $output_type_candidate; 384 | + } else { 385 | + $output_type = Type::combineUnionTypes( 386 | + $output_type_candidate, 387 | + $output_type 388 | + ); 389 | + } 390 | } 391 | 392 | - $template_types[$template_name] = [new Type\Union([$type]), $class_storage->name]; 393 | + 394 | + $template_types[$template_name] = [$output_type ?: Type::getMixed(), $class_storage->name]; 395 | } 396 | } 397 | } 398 | diff --git a/src/Psalm/Internal/Analyzer/TypeAnalyzer.php b/src/Psalm/Internal/Analyzer/TypeAnalyzer.php 399 | index 41edbf169..b9772b430 100644 400 | --- a/src/Psalm/Internal/Analyzer/TypeAnalyzer.php 401 | +++ b/src/Psalm/Internal/Analyzer/TypeAnalyzer.php 402 | @@ -1340,7 +1340,7 @@ private static function isMatchingTypeContainedBy( 403 | 404 | foreach ($extends_list as $key => $value) { 405 | if (is_string($key)) { 406 | - $generic_params[] = new Type\Union([$value]); 407 | + $generic_params[] = $value; 408 | } 409 | } 410 | 411 | @@ -1377,21 +1377,36 @@ private static function isMatchingTypeContainedBy( 412 | 413 | $new_input_params = []; 414 | 415 | - foreach ($params as $key => $atomic_input_param) { 416 | + foreach ($params as $key => $extended_input_param_type) { 417 | if (is_string($key)) { 418 | - if ($atomic_input_param instanceof TTemplateParam 419 | - && $atomic_input_param->param_name 420 | - && isset($input_class_storage->template_types[$atomic_input_param->param_name]) 421 | - ) { 422 | - $old_params_offset = (int) array_search( 423 | - $atomic_input_param->param_name, 424 | - array_keys($input_class_storage->template_types) 425 | - ); 426 | - 427 | - $new_input_params[] = $input_type_params[$old_params_offset]; 428 | - } else { 429 | - $new_input_params[] = new Type\Union([$atomic_input_param]); 430 | + $new_input_param = null; 431 | + 432 | + foreach ($extended_input_param_type->getTypes() as $et) { 433 | + if ($et instanceof TTemplateParam 434 | + && $et->param_name 435 | + && isset($input_class_storage->template_types[$et->param_name]) 436 | + ) { 437 | + $old_params_offset = (int) array_search( 438 | + $et->param_name, 439 | + array_keys($input_class_storage->template_types) 440 | + ); 441 | + 442 | + $candidate_param_type = $input_type_params[$old_params_offset]; 443 | + } else { 444 | + $candidate_param_type = new Type\Union([$et]); 445 | + } 446 | + 447 | + if (!$new_input_param) { 448 | + $new_input_param = $candidate_param_type; 449 | + } else { 450 | + $new_input_param = Type::combineUnionTypes( 451 | + $new_input_param, 452 | + $candidate_param_type 453 | + ); 454 | + } 455 | } 456 | + 457 | + $new_input_params[] = $new_input_param ?: Type::getMixed(); 458 | } 459 | } 460 | 461 | diff --git a/src/Psalm/Internal/Codebase/Methods.php b/src/Psalm/Internal/Codebase/Methods.php 462 | index 74f86204f..b952fc613 100644 463 | --- a/src/Psalm/Internal/Codebase/Methods.php 464 | +++ b/src/Psalm/Internal/Codebase/Methods.php 465 | @@ -296,7 +296,7 @@ function (FunctionLikeParameter $p) { 466 | $params[$i]->type = clone $overridden_storage->params[$i]->type; 467 | 468 | if ($params[$i]->type && $source) { 469 | - self::localizeParamType( 470 | + $params[$i]->type = self::localizeParamType( 471 | $source->getCodebase(), 472 | $params[$i]->type, 473 | $appearing_fq_class_name, 474 | @@ -318,22 +318,21 @@ function (FunctionLikeParameter $p) { 475 | throw new \UnexpectedValueException('Cannot get method params for ' . $method_id); 476 | } 477 | 478 | - /** 479 | - * @return void 480 | - */ 481 | private static function localizeParamType( 482 | Codebase $codebase, 483 | Type\Union $type, 484 | string $appearing_fq_class_name, 485 | string $base_fq_class_name 486 | - ) { 487 | + ) : Type\Union { 488 | $class_storage = $codebase->classlike_storage_provider->get($appearing_fq_class_name); 489 | $extends = $class_storage->template_type_extends; 490 | 491 | if (!$extends) { 492 | - return; 493 | + return $type; 494 | } 495 | 496 | + $type = clone $type; 497 | + 498 | foreach ($type->getTypes() as $key => $atomic_type) { 499 | if ($atomic_type instanceof Type\Atomic\TTemplateParam 500 | || $atomic_type instanceof Type\Atomic\TTemplateParamClass 501 | @@ -345,7 +344,11 @@ private static function localizeParamType( 502 | $extended_param = $extends[strtolower($base_fq_class_name)][$atomic_type->param_name]; 503 | 504 | $type->removeType($key); 505 | - $type->addType($extended_param); 506 | + $type = Type::combineUnionTypes( 507 | + $type, 508 | + $extended_param, 509 | + $codebase 510 | + ); 511 | } 512 | } 513 | } 514 | @@ -355,7 +358,7 @@ private static function localizeParamType( 515 | || $atomic_type instanceof Type\Atomic\TGenericObject 516 | ) { 517 | foreach ($atomic_type->type_params as $type_param) { 518 | - self::localizeParamType( 519 | + $type_param = self::localizeParamType( 520 | $codebase, 521 | $type_param, 522 | $appearing_fq_class_name, 523 | @@ -370,7 +373,7 @@ private static function localizeParamType( 524 | if ($atomic_type->params) { 525 | foreach ($atomic_type->params as $param) { 526 | if ($param->type) { 527 | - self::localizeParamType( 528 | + $param->type = self::localizeParamType( 529 | $codebase, 530 | $param->type, 531 | $appearing_fq_class_name, 532 | @@ -381,7 +384,7 @@ private static function localizeParamType( 533 | } 534 | 535 | if ($atomic_type->return_type) { 536 | - self::localizeParamType( 537 | + $atomic_type->return_type = self::localizeParamType( 538 | $codebase, 539 | $atomic_type->return_type, 540 | $appearing_fq_class_name, 541 | @@ -390,6 +393,8 @@ private static function localizeParamType( 542 | } 543 | } 544 | } 545 | + 546 | + return $type; 547 | } 548 | 549 | /** 550 | diff --git a/src/Psalm/Internal/Codebase/Populator.php b/src/Psalm/Internal/Codebase/Populator.php 551 | index 2113e3526..b61a39901 100644 552 | --- a/src/Psalm/Internal/Codebase/Populator.php 553 | +++ b/src/Psalm/Internal/Codebase/Populator.php 554 | @@ -323,19 +323,10 @@ private function populateDataFromTraits( 555 | continue; 556 | } 557 | 558 | - if ($type instanceof Type\Atomic\TTemplateParam 559 | - && $type->defining_class 560 | - && ($referenced_type 561 | - = $storage->template_type_extends 562 | - [strtolower($type->defining_class)] 563 | - [$type->param_name] 564 | - ?? null) 565 | - && (!$referenced_type instanceof Type\Atomic\TTemplateParam) 566 | - ) { 567 | - $storage->template_type_extends[$t_storage_class][$i] = $referenced_type; 568 | - } else { 569 | - $storage->template_type_extends[$t_storage_class][$i] = $type; 570 | - } 571 | + $storage->template_type_extends[$t_storage_class][$i] = self::extendType( 572 | + $type, 573 | + $storage 574 | + ); 575 | } 576 | } 577 | } 578 | @@ -343,8 +334,7 @@ private function populateDataFromTraits( 579 | $storage->template_type_extends[$used_trait_lc] = []; 580 | 581 | foreach ($trait_storage->template_types as $template_name => $template_type) { 582 | - $storage->template_type_extends[$used_trait_lc][$template_name] 583 | - = array_values($template_type[0]->getTypes())[0]; 584 | + $storage->template_type_extends[$used_trait_lc][$template_name] = $template_type[0]; 585 | } 586 | } 587 | } elseif ($trait_storage->template_type_extends) { 588 | @@ -356,6 +346,41 @@ private function populateDataFromTraits( 589 | } 590 | } 591 | 592 | + private static function extendType( 593 | + Type\Union $type, 594 | + ClassLikeStorage $storage 595 | + ) : Type\Union { 596 | + $extended_types = []; 597 | + 598 | + foreach ($type->getTypes() as $atomic_type) { 599 | + if ($atomic_type instanceof Type\Atomic\TTemplateParam 600 | + && $atomic_type->defining_class 601 | + ) { 602 | + $referenced_type 603 | + = $storage->template_type_extends 604 | + [strtolower($atomic_type->defining_class)] 605 | + [$atomic_type->param_name] 606 | + ?? null; 607 | + 608 | + if ($referenced_type) { 609 | + foreach ($referenced_type->getTypes() as $atomic_referenced_type) { 610 | + if (!$atomic_referenced_type instanceof Type\Atomic\TTemplateParam) { 611 | + $extended_types[] = $atomic_referenced_type; 612 | + } else { 613 | + $extended_types[] = $atomic_type; 614 | + } 615 | + } 616 | + } else { 617 | + $extended_types[] = $atomic_type; 618 | + } 619 | + } else { 620 | + $extended_types[] = $atomic_type; 621 | + } 622 | + } 623 | + 624 | + return new Type\Union($extended_types); 625 | + } 626 | + 627 | /** 628 | * @return void 629 | */ 630 | @@ -406,19 +431,10 @@ private function populateDataFromParentClass( 631 | continue; 632 | } 633 | 634 | - if ($type instanceof Type\Atomic\TTemplateParam 635 | - && $type->defining_class 636 | - && ($referenced_type 637 | - = $storage->template_type_extends 638 | - [strtolower($type->defining_class)] 639 | - [$type->param_name] 640 | - ?? null) 641 | - && (!$referenced_type instanceof Type\Atomic\TTemplateParam) 642 | - ) { 643 | - $storage->template_type_extends[$t_storage_class][$i] = $referenced_type; 644 | - } else { 645 | - $storage->template_type_extends[$t_storage_class][$i] = $type; 646 | - } 647 | + $storage->template_type_extends[$t_storage_class][$i] = self::extendType( 648 | + $type, 649 | + $storage 650 | + ); 651 | } 652 | } 653 | } 654 | @@ -427,7 +443,7 @@ private function populateDataFromParentClass( 655 | 656 | foreach ($parent_storage->template_types as $template_name => $template_type) { 657 | $storage->template_type_extends[$parent_storage_class][$template_name] 658 | - = array_values($template_type[0]->getTypes())[0]; 659 | + = $template_type[0]; 660 | } 661 | } 662 | } elseif ($parent_storage->template_type_extends) { 663 | @@ -539,19 +555,10 @@ private function populateInterfaceDataFromParentInterfaces( 664 | continue; 665 | } 666 | 667 | - if ($type instanceof Type\Atomic\TTemplateParam 668 | - && $type->defining_class 669 | - && ($referenced_type 670 | - = $storage->template_type_extends 671 | - [strtolower($type->defining_class)] 672 | - [$type->param_name] 673 | - ?? null) 674 | - && (!$referenced_type instanceof Type\Atomic\TTemplateParam) 675 | - ) { 676 | - $storage->template_type_extends[$t_storage_class][$i] = $referenced_type; 677 | - } else { 678 | - $storage->template_type_extends[$t_storage_class][$i] = $type; 679 | - } 680 | + $storage->template_type_extends[$t_storage_class][$i] = self::extendType( 681 | + $type, 682 | + $storage 683 | + ); 684 | } 685 | } 686 | } 687 | @@ -560,7 +567,7 @@ private function populateInterfaceDataFromParentInterfaces( 688 | 689 | foreach ($parent_interface_storage->template_types as $template_name => $template_type) { 690 | $storage->template_type_extends[$parent_interface_lc][$template_name] 691 | - = array_values($template_type[0]->getTypes())[0]; 692 | + = $template_type[0]; 693 | } 694 | } 695 | } 696 | @@ -641,7 +648,7 @@ private function populateDataFromImplementedInterfaces( 697 | 698 | foreach ($implemented_interface_storage->template_types as $template_name => $template_type) { 699 | $storage->template_type_extends[$implemented_interface_lc][$template_name] 700 | - = array_values($template_type[0]->getTypes())[0]; 701 | + = $template_type[0]; 702 | } 703 | } 704 | } 705 | diff --git a/src/Psalm/Internal/Visitor/ReflectorVisitor.php b/src/Psalm/Internal/Visitor/ReflectorVisitor.php 706 | index cafcea450..1f1e06ab6 100644 707 | --- a/src/Psalm/Internal/Visitor/ReflectorVisitor.php 708 | +++ b/src/Psalm/Internal/Visitor/ReflectorVisitor.php 709 | @@ -1090,20 +1090,7 @@ private function extendTemplatedType( 710 | $storage->template_type_extends_count = count($atomic_type->type_params); 711 | 712 | foreach ($atomic_type->type_params as $type_param) { 713 | - if (!$type_param->isSingle()) { 714 | - if (IssueBuffer::accepts( 715 | - new InvalidDocblock( 716 | - '@template-extends type parameter cannot be a union type', 717 | - new CodeLocation($this->file_scanner, $node, null, true) 718 | - ) 719 | - )) { 720 | - } 721 | - return; 722 | - } 723 | - 724 | - foreach ($type_param->getTypes() as $type_param_atomic) { 725 | - $extended_type_parameters[] = $type_param_atomic; 726 | - } 727 | + $extended_type_parameters[] = $type_param; 728 | } 729 | 730 | if ($extended_type_parameters) { 731 | @@ -1185,20 +1172,7 @@ private function implementTemplatedType( 732 | $storage->template_type_implements_count[$generic_class_lc] = count($atomic_type->type_params); 733 | 734 | foreach ($atomic_type->type_params as $type_param) { 735 | - if (!$type_param->isSingle()) { 736 | - if (IssueBuffer::accepts( 737 | - new InvalidDocblock( 738 | - '@template-implements type parameter cannot be a union type', 739 | - new CodeLocation($this->file_scanner, $node, null, true) 740 | - ) 741 | - )) { 742 | - } 743 | - return; 744 | - } 745 | - 746 | - foreach ($type_param->getTypes() as $type_param_atomic) { 747 | - $implemented_type_parameters[] = $type_param_atomic; 748 | - } 749 | + $implemented_type_parameters[] = $type_param; 750 | } 751 | 752 | if ($implemented_type_parameters) { 753 | @@ -1280,20 +1254,7 @@ private function useTemplatedType( 754 | $storage->template_type_uses_count[$generic_class_lc] = count($atomic_type->type_params); 755 | 756 | foreach ($atomic_type->type_params as $type_param) { 757 | - if (!$type_param->isSingle()) { 758 | - if (IssueBuffer::accepts( 759 | - new InvalidDocblock( 760 | - '@template-uses type parameter cannot be a union type', 761 | - new CodeLocation($this->file_scanner, $node, null, true) 762 | - ) 763 | - )) { 764 | - } 765 | - return; 766 | - } 767 | - 768 | - foreach ($type_param->getTypes() as $type_param_atomic) { 769 | - $used_type_parameters[] = $type_param_atomic; 770 | - } 771 | + $used_type_parameters[] = $type_param; 772 | } 773 | 774 | if ($used_type_parameters) { 775 | diff --git a/src/Psalm/Storage/ClassLikeStorage.php b/src/Psalm/Storage/ClassLikeStorage.php 776 | index eb2c01c0b..fe8f274d3 100644 777 | --- a/src/Psalm/Storage/ClassLikeStorage.php 778 | +++ b/src/Psalm/Storage/ClassLikeStorage.php 779 | @@ -273,7 +273,7 @@ class ClassLikeStorage 780 | public $template_types; 781 | 782 | /** 783 | - * @var array>|null 784 | + * @var array>|null 785 | */ 786 | public $template_type_extends; 787 | 788 | diff --git a/src/Psalm/Type/Union.php b/src/Psalm/Type/Union.php 789 | index 594a2c242..e8d1b5f07 100644 790 | --- a/src/Psalm/Type/Union.php 791 | +++ b/src/Psalm/Type/Union.php 792 | @@ -855,27 +855,33 @@ public function replaceTemplateTypesWithStandins( 793 | ) { 794 | $keys_to_unset = []; 795 | 796 | + $new_types = []; 797 | + 798 | foreach ($this->types as $key => $atomic_type) { 799 | if ($atomic_type instanceof Type\Atomic\TTemplateParam 800 | && isset($template_types[$key]) 801 | && $atomic_type->defining_class === $template_types[$key][1] 802 | ) { 803 | if ($template_types[$key][0]->getId() !== $key) { 804 | - $first_atomic_type = array_values($template_types[$key][0]->getTypes())[0]; 805 | + $replacement_type = $template_types[$key][0]; 806 | 807 | if ($replace) { 808 | - if ($first_atomic_type instanceof Type\Atomic\TMixed 809 | + if ($replacement_type->hasMixed() 810 | && !$atomic_type->as->hasMixed() 811 | ) { 812 | - $this->types[$first_atomic_type->getKey()] = clone array_values( 813 | - $atomic_type->as->getTypes() 814 | - )[0]; 815 | + foreach ($atomic_type->as->getTypes() as $as_atomic_type) { 816 | + $this->types[$as_atomic_type->getKey()] = clone $as_atomic_type; 817 | + } 818 | } else { 819 | - $this->types[$first_atomic_type->getKey()] = clone $first_atomic_type; 820 | - } 821 | + foreach ($replacement_type->getTypes() as $replacement_atomic_type) { 822 | + $this->types[$replacement_atomic_type->getKey()] = clone $replacement_atomic_type; 823 | + } 824 | 825 | - if ($first_atomic_type->getKey() !== $key) { 826 | - $keys_to_unset[] = $key; 827 | + foreach ($replacement_type->getTypes() as $replacement_key => $_) { 828 | + if ($replacement_key !== $key) { 829 | + $keys_to_unset[] = $key; 830 | + } 831 | + } 832 | } 833 | 834 | if ($input_type) { 835 | @@ -1025,7 +1031,7 @@ public function replaceTemplateTypesWithStandins( 836 | 837 | foreach ($extends_list as $key => $value) { 838 | if (is_int($key)) { 839 | - $new_generic_params[] = new Type\Union([$value]); 840 | + $new_generic_params[] = $value; 841 | } 842 | } 843 | 844 | diff --git a/tests/ArrayAssignmentTest.php b/tests/ArrayAssignmentTest.php 845 | index bd33e192d..ed8b68705 100644 846 | --- a/tests/ArrayAssignmentTest.php 847 | +++ b/tests/ArrayAssignmentTest.php 848 | @@ -1056,10 +1056,10 @@ function foo(array $arr) : void { 849 | 'assertions' => [], 850 | 'error_levels' => ['MixedAssignment'], 851 | ], 852 | - 'SKIPPED-implementsArrayAccessAllowNullOffset' => [ 853 | + 'implementsArrayAccessAllowNullOffset' => [ 854 | ' 857 | + * @template-implements ArrayAccess 858 | */ 859 | class C implements ArrayAccess { 860 | public function offsetExists(int $offset) : bool { return true; } 861 | diff --git a/tests/Template/TemplateExtendsTest.php b/tests/Template/TemplateExtendsTest.php 862 | index fc1acea11..1dc90e1ce 100644 863 | --- a/tests/Template/TemplateExtendsTest.php 864 | +++ b/tests/Template/TemplateExtendsTest.php 865 | @@ -1457,6 +1457,76 @@ function foo(C $c) : void { 866 | $c->add(new stdClass); 867 | }', 868 | ], 869 | + 'templateExtendsUnionType' => [ 870 | + 't = $t; 881 | + } 882 | + } 883 | + 884 | + /** 885 | + * @template TT 886 | + * @template-extends A 887 | + */ 888 | + class B extends A {}', 889 | + ], 890 | + 'badTemplateImplementsUnionType' => [ 891 | + ' 903 | + */ 904 | + class B implements I { 905 | + /** @var int|string */ 906 | + public $t; 907 | + 908 | + /** @param int|string $t */ 909 | + public function __construct($t) { 910 | + $this->t = $t; 911 | + } 912 | + }', 913 | + ], 914 | + 'badTemplateUseUnionType' => [ 915 | + 't = $t; 926 | + } 927 | + } 928 | + 929 | + /** 930 | + * @template TT 931 | + */ 932 | + class B { 933 | + /** 934 | + * @template-use T 935 | + */ 936 | + use T; 937 | + }', 938 | + ], 939 | ]; 940 | } 941 | 942 | @@ -1768,28 +1838,6 @@ public function __construct($t) { 943 | class B extends A {}', 944 | 'error_message' => 'InvalidDocblock' 945 | ], 946 | - 'badTemplateExtendsUnionType' => [ 947 | - 't = $t; 958 | - } 959 | - } 960 | - 961 | - /** 962 | - * @template TT 963 | - * @template-extends A 964 | - */ 965 | - class B extends A {}', 966 | - 'error_message' => 'InvalidDocblock' 967 | - ], 968 | 'badTemplateImplementsShouldBeExtends' => [ 969 | ' 'InvalidDocblock' 974 | ], 975 | - 'badTemplateImplementsUnionType' => [ 976 | - ' 988 | - */ 989 | - class B implements I {}', 990 | - 'error_message' => 'InvalidDocblock' 991 | - ], 992 | 'badTemplateExtendsShouldBeImplements' => [ 993 | ' 'InvalidDocblock' 998 | ], 999 | - 'badTemplateUseUnionType' => [ 1000 | - 't = $t; 1011 | - } 1012 | - } 1013 | - 1014 | - /** 1015 | - * @template TT 1016 | - */ 1017 | - class B { 1018 | - /** 1019 | - * @template-use T 1020 | - */ 1021 | - use T; 1022 | - }', 1023 | - 'error_message' => 'InvalidDocblock' 1024 | - ], 1025 | 'templateExtendsWithoutAllParams' => [ 1026 | ' [ 1037 | + ' $y 1042 | + */ 1043 | + function example($x, $y): void {} 1044 | + 1045 | + example( 1046 | + /** @param int|false $x */ 1047 | + function($x): void {}, 1048 | + [strpos("str", "str")] 1049 | + );' 1050 | + ], 1051 | ]; 1052 | } 1053 | --------------------------------------------------------------------------------