From ecea71afaffd72f6c412d17d1bf2eeace3a7ef2f Mon Sep 17 00:00:00 2001 From: William Allen Date: Mon, 2 Feb 2026 14:20:25 -0500 Subject: [PATCH] Expose all project fields via GraphQL This PR adds all of the missing Project fields and solidifies the database types to better enforce our constraints. I plan to follow this work with a new `updateProject` mutation. --- app/GraphQL/Scalars/Time.php | 64 ++++++++++ app/Models/Project.php | 18 +-- app/cdash/app/Lib/Repository/GitHub.php | 2 +- app/cdash/app/Model/BuildError.php | 2 +- app/cdash/app/Model/BuildFailure.php | 6 +- app/cdash/app/Model/Project.php | 34 ++--- app/cdash/app/Model/Repository.php | 2 +- app/cdash/include/common.php | 8 +- app/cdash/tests/test_builddetails.php | 2 +- ...2026_01_30_045544_project_column_types.php | 100 +++++++++++++++ graphql/schema.graphql | 120 ++++++++++++++++++ phpstan-baseline.neon | 84 ++++-------- tests/Browser/Pages/SitesIdPageTest.php | 4 +- tests/Feature/GraphQL/ProjectTypeTest.php | 110 ++++++++++++++++ 14 files changed, 457 insertions(+), 99 deletions(-) create mode 100644 app/GraphQL/Scalars/Time.php create mode 100644 database/migrations/2026_01_30_045544_project_column_types.php diff --git a/app/GraphQL/Scalars/Time.php b/app/GraphQL/Scalars/Time.php new file mode 100644 index 0000000000..5787c442d8 --- /dev/null +++ b/app/GraphQL/Scalars/Time.php @@ -0,0 +1,64 @@ +validateTime($value)) { + throw new InvariantViolation("The value $value is not a valid Time format (H:i:s)."); + } + + return $value; + } + + /** + * @param ValueNode&Node $valueNode + * @param array|null $variables + * + * @throws InvariantViolation + */ + public function parseLiteral(Node $valueNode, ?array $variables = null): string + { + if (!property_exists($valueNode, 'value')) { + throw new InvariantViolation('Time must be a string.'); + } + + if (!$this->validateTime($valueNode->value)) { + throw new InvariantViolation("The value {$valueNode->value} is not a valid Time."); + } + + return $valueNode->value; + } + + private function validateTime(string $time): bool + { + // Simple regex for HH:MM:SS* format to make sure it's actually a time, not a datetime. + if (preg_match('/^([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]/', $time) !== 1) { + return false; + } + + try { + Carbon::parse($time); + return true; + } catch (InvalidFormatException) { + return false; + } + } +} diff --git a/app/Models/Project.php b/app/Models/Project.php index bbeef38cf4..b6f84fc70b 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -14,23 +14,23 @@ /** * @property int $id * @property string $name - * @property string $description - * @property string $homeurl - * @property string $cvsurl - * @property string $bugtrackerurl - * @property string $bugtrackernewissueurl - * @property string $bugtrackertype - * @property string $documentationurl + * @property ?string $description + * @property ?string $homeurl + * @property ?string $cvsurl + * @property ?string $bugtrackerurl + * @property ?string $bugtrackernewissueurl + * @property ?string $bugtrackertype + * @property ?string $documentationurl * @property int $imageid * @property int $public * @property int $coveragethreshold - * @property string $testingdataurl + * @property ?string $testingdataurl * @property string $nightlytime * @property bool $emaillowcoverage * @property bool $emailtesttimingchanged * @property bool $emailbrokensubmission * @property bool $emailredundantfailures - * @property string $cvsviewertype + * @property ?string $cvsviewertype * @property int $testtimestd * @property int $testtimestdthreshold * @property bool $showtesttime diff --git a/app/cdash/app/Lib/Repository/GitHub.php b/app/cdash/app/Lib/Repository/GitHub.php index ac68bd4914..3a3779adf6 100644 --- a/app/cdash/app/Lib/Repository/GitHub.php +++ b/app/cdash/app/Lib/Repository/GitHub.php @@ -664,7 +664,7 @@ public function getRepository(): string protected function getRepositoryInformation(): void { - $url = str_replace('//', '', $this->project->CvsUrl); + $url = str_replace('//', '', $this->project->CvsUrl ?? ''); $parts = explode('/', $url); if (isset($parts[1])) { $this->owner = $parts[1]; diff --git a/app/cdash/app/Model/BuildError.php b/app/cdash/app/Model/BuildError.php index 6b69fe0d6e..009ace109e 100644 --- a/app/cdash/app/Model/BuildError.php +++ b/app/cdash/app/Model/BuildError.php @@ -110,7 +110,7 @@ public static function marshal(array $data, Project $project, $revision): array 'logline' => (int) $data['logline'], 'cvsurl' => RepositoryUtils::get_diff_url($project->Id, $project->CvsUrl, $sourceFile['directory'], $sourceFile['file'], $revision), 'precontext' => '', - 'text' => RepositoryUtils::linkify_compiler_output($project->CvsUrl, $source_dir, $revision, $data['stdoutput']), + 'text' => RepositoryUtils::linkify_compiler_output($project->CvsUrl ?? '', $source_dir, $revision, $data['stdoutput']), 'postcontext' => '', 'sourcefile' => $data['sourcefile'], 'sourceline' => $data['sourceline'], diff --git a/app/cdash/app/Model/BuildFailure.php b/app/cdash/app/Model/BuildFailure.php index a1d43cc89c..65ea61ef66 100644 --- a/app/cdash/app/Model/BuildFailure.php +++ b/app/cdash/app/Model/BuildFailure.php @@ -255,7 +255,7 @@ public static function marshal($data, Project $project, $revision, $linkifyOutpu $file = basename($data['sourcefile']); $directory = dirname($data['sourcefile']); - $source_dir = RepositoryUtils::get_source_dir($project->Id, $project->CvsUrl, $directory); + $source_dir = RepositoryUtils::get_source_dir($project->Id, $project->CvsUrl ?? '', $directory); if (str_starts_with($directory, $source_dir)) { $directory = substr($directory, strlen($source_dir)); } @@ -267,9 +267,9 @@ public static function marshal($data, Project $project, $revision, $linkifyOutpu $revision); if ($source_dir !== null && $linkifyOutput) { - $marshaled['stderror'] = RepositoryUtils::linkify_compiler_output($project->CvsUrl, $source_dir, + $marshaled['stderror'] = RepositoryUtils::linkify_compiler_output($project->CvsUrl ?? '', $source_dir, $revision, $data['stderror']); - $marshaled['stdoutput'] = RepositoryUtils::linkify_compiler_output($project->CvsUrl, $source_dir, + $marshaled['stdoutput'] = RepositoryUtils::linkify_compiler_output($project->CvsUrl ?? '', $source_dir, $revision, $data['stdoutput']); } } diff --git a/app/cdash/app/Model/Project.php b/app/cdash/app/Model/Project.php index 0a22968019..97b7012874 100644 --- a/app/cdash/app/Model/Project.php +++ b/app/cdash/app/Model/Project.php @@ -43,17 +43,17 @@ class Project { public $Name; public $Id; - public $Description; - public $HomeUrl; - public $CvsUrl; - public $DocumentationUrl; - public $BugTrackerUrl; - public $BugTrackerNewIssueUrl; - public $BugTrackerType; + public ?string $Description = null; + public ?string $HomeUrl = null; + public ?string $CvsUrl = null; + public ?string $DocumentationUrl = null; + public ?string $BugTrackerUrl = null; + public ?string $BugTrackerNewIssueUrl = null; + public ?string $BugTrackerType = null; public ?int $ImageId = null; public $Public; public $CoverageThreshold; - public $TestingDataUrl; + public ?string $TestingDataUrl = null; public $NightlyTime; public $NightlyDateTime; public $NightlyTimezone; @@ -61,7 +61,7 @@ class Project public $EmailTestTimingChanged = 0; public $EmailBrokenSubmission = 0; public $EmailRedundantFailures = 0; - public $CvsViewerType; + public ?string $CvsViewerType = null; public $TestTimeStd; public $TestTimeStdThreshold; public $ShowTestTime = 0; @@ -115,15 +115,15 @@ public function Save(): bool $project->fill([ 'name' => $this->Name ?? '', 'description' => $this->Description, - 'homeurl' => $this->HomeUrl ?? '', - 'cvsurl' => $this->CvsUrl ?? '', - 'documentationurl' => $this->DocumentationUrl ?? '', - 'bugtrackerurl' => $this->BugTrackerUrl ?? '', - 'bugtrackernewissueurl' => $this->BugTrackerNewIssueUrl ?? '', - 'bugtrackertype' => $this->BugTrackerType ?? '', + 'homeurl' => $this->HomeUrl, + 'cvsurl' => $this->CvsUrl, + 'documentationurl' => $this->DocumentationUrl, + 'bugtrackerurl' => $this->BugTrackerUrl, + 'bugtrackernewissueurl' => $this->BugTrackerNewIssueUrl, + 'bugtrackertype' => $this->BugTrackerType, 'public' => (int) $this->Public, 'coveragethreshold' => (int) $this->CoverageThreshold, - 'testingdataurl' => $this->TestingDataUrl ?? '', + 'testingdataurl' => $this->TestingDataUrl, 'nightlytime' => $this->NightlyTime ?? '', 'emaillowcoverage' => (bool) $this->EmailLowCoverage, 'emailtesttimingchanged' => (bool) $this->EmailTestTimingChanged, @@ -137,7 +137,7 @@ public function Save(): bool 'autoremovetimeframe' => (int) $this->AutoremoveTimeframe, 'autoremovemaxbuilds' => (int) $this->AutoremoveMaxBuilds, 'uploadquota' => (int) $this->UploadQuota, - 'cvsviewertype' => $this->CvsViewerType ?? '', + 'cvsviewertype' => $this->CvsViewerType, 'testtimestd' => (int) $this->TestTimeStd, 'testtimestdthreshold' => (int) $this->TestTimeStdThreshold, 'showtesttime' => (bool) $this->ShowTestTime, diff --git a/app/cdash/app/Model/Repository.php b/app/cdash/app/Model/Repository.php index 205fbc41a1..6dfa474129 100644 --- a/app/cdash/app/Model/Repository.php +++ b/app/cdash/app/Model/Repository.php @@ -107,7 +107,7 @@ protected static function getRepositoryService(Project $project): ?RepositorySer public static function getRepositoryInterface(Project $project): RepositoryInterface { - switch (strtolower($project->CvsViewerType)) { + switch (strtolower($project->CvsViewerType ?? '')) { case strtolower(self::VIEWER_GITHUB): $service = new GitHub($project); break; diff --git a/app/cdash/include/common.php b/app/cdash/include/common.php index 16e18d17ec..f0d8181218 100644 --- a/app/cdash/include/common.php +++ b/app/cdash/include/common.php @@ -572,10 +572,10 @@ function get_dashboard_JSON($projectname, $date, &$response): void $project->FindByName($projectname); $project_array = []; - $project_array['cvsurl'] = $project->Id ? $project->CvsUrl : 'unknown'; - $project_array['bugtrackerurl'] = $project->Id ? $project->BugTrackerUrl : 'unknown'; - $project_array['documentationurl'] = $project->Id ? $project->DocumentationUrl : 'unknown'; - $project_array['homeurl'] = $project->Id ? $project->HomeUrl : 'unknown'; + $project_array['cvsurl'] = $project->CvsUrl ?? ''; + $project_array['bugtrackerurl'] = $project->BugTrackerUrl ?? ''; + $project_array['documentationurl'] = $project->DocumentationUrl ?? ''; + $project_array['homeurl'] = $project->HomeUrl ?? ''; $project_array['name'] = $projectname; $project_array['nightlytime'] = $project->Id ? $project->NightlyTime : '00:00:00'; diff --git a/app/cdash/tests/test_builddetails.php b/app/cdash/tests/test_builddetails.php index 43188abf8c..84bc8a6ad7 100644 --- a/app/cdash/tests/test_builddetails.php +++ b/app/cdash/tests/test_builddetails.php @@ -24,7 +24,7 @@ public function __construct() $this->createProject([ 'Name' => 'BuildDetails', - 'CvsViewerType' => 'viewcvs', + 'CvsViewerType' => null, ]); foreach ($this->testDataFiles as $testDataFile) { diff --git a/database/migrations/2026_01_30_045544_project_column_types.php b/database/migrations/2026_01_30_045544_project_column_types.php new file mode 100644 index 0000000000..e2796d3447 --- /dev/null +++ b/database/migrations/2026_01_30_045544_project_column_types.php @@ -0,0 +1,100 @@ +|string) given.' - identifier: argument.type - count: 1 - path: app/cdash/app/Lib/Repository/GitHub.php - - rawMessage: Possibly invalid array key type mixed. identifier: offsetAccess.invalidOffset @@ -11082,60 +11100,18 @@ parameters: count: 1 path: app/cdash/app/Model/Project.php - - - rawMessage: Property CDash\Model\Project::$BugTrackerNewIssueUrl has no type specified. - identifier: missingType.property - count: 1 - path: app/cdash/app/Model/Project.php - - - - rawMessage: Property CDash\Model\Project::$BugTrackerType has no type specified. - identifier: missingType.property - count: 1 - path: app/cdash/app/Model/Project.php - - - - rawMessage: Property CDash\Model\Project::$BugTrackerUrl has no type specified. - identifier: missingType.property - count: 1 - path: app/cdash/app/Model/Project.php - - rawMessage: Property CDash\Model\Project::$CoverageThreshold has no type specified. identifier: missingType.property count: 1 path: app/cdash/app/Model/Project.php - - - rawMessage: Property CDash\Model\Project::$CvsUrl has no type specified. - identifier: missingType.property - count: 1 - path: app/cdash/app/Model/Project.php - - - - rawMessage: Property CDash\Model\Project::$CvsViewerType has no type specified. - identifier: missingType.property - count: 1 - path: app/cdash/app/Model/Project.php - - - - rawMessage: Property CDash\Model\Project::$Description has no type specified. - identifier: missingType.property - count: 1 - path: app/cdash/app/Model/Project.php - - rawMessage: Property CDash\Model\Project::$DisplayLabels has no type specified. identifier: missingType.property count: 1 path: app/cdash/app/Model/Project.php - - - rawMessage: Property CDash\Model\Project::$DocumentationUrl has no type specified. - identifier: missingType.property - count: 1 - path: app/cdash/app/Model/Project.php - - rawMessage: Property CDash\Model\Project::$EmailBrokenSubmission has no type specified. identifier: missingType.property @@ -11178,12 +11154,6 @@ parameters: count: 1 path: app/cdash/app/Model/Project.php - - - rawMessage: Property CDash\Model\Project::$HomeUrl has no type specified. - identifier: missingType.property - count: 1 - path: app/cdash/app/Model/Project.php - - rawMessage: Property CDash\Model\Project::$Id has no type specified. identifier: missingType.property @@ -11256,12 +11226,6 @@ parameters: count: 1 path: app/cdash/app/Model/Project.php - - - rawMessage: Property CDash\Model\Project::$TestingDataUrl has no type specified. - identifier: missingType.property - count: 1 - path: app/cdash/app/Model/Project.php - - rawMessage: Property CDash\Model\Project::$UploadQuota has no type specified. identifier: missingType.property diff --git a/tests/Browser/Pages/SitesIdPageTest.php b/tests/Browser/Pages/SitesIdPageTest.php index 130e8f80b3..212a76abe3 100644 --- a/tests/Browser/Pages/SitesIdPageTest.php +++ b/tests/Browser/Pages/SitesIdPageTest.php @@ -125,8 +125,8 @@ public function testProjectsList(): void ->whenAvailable('@site-projects-table', function (Browser $browser): void { $browser->assertSee($this->projects['public1']->name); $browser->assertSee($this->projects['public2']->name); - $browser->assertSee($this->projects['public1']->description); - $browser->assertSee($this->projects['public2']->description); + $browser->assertSee((string) $this->projects['public1']->description); + $browser->assertSee((string) $this->projects['public2']->description); }); }); } diff --git a/tests/Feature/GraphQL/ProjectTypeTest.php b/tests/Feature/GraphQL/ProjectTypeTest.php index f00b9a21fb..3bafc4d169 100644 --- a/tests/Feature/GraphQL/ProjectTypeTest.php +++ b/tests/Feature/GraphQL/ProjectTypeTest.php @@ -92,6 +92,116 @@ protected function tearDown(): void parent::tearDown(); } + /** + * @return array{ + * array{ + * string, mixed, string, mixed, + * } + * } + */ + public static function fieldValues(): array + { + // TODO: once VcsViewer is an enum, get cases dynamically. + $vcsViewerValues = [ + ['cvsviewertype', 'github', 'vcsViewer', 'GITHUB'], + ['cvsviewertype', 'gitlab', 'vcsViewer', 'GITLAB'], + ['cvsviewertype', null, 'vcsViewer', null], + ]; + + // TODO: once BugTracker is an enum, get cases dynamically. + $bugTrackerValues = [ + ['bugtrackertype', 'GitHub', 'bugTracker', 'GITHUB'], + ['bugtrackertype', 'Buganizer', 'bugTracker', 'BUGANIZER'], + ['bugtrackertype', 'JIRA', 'bugTracker', 'JIRA'], + ['bugtrackertype', null, 'bugTracker', null], + ]; + + return [ + ['description', 'abc', 'description', 'abc'], + ['description', null, 'description', null], + ['homeurl', 'https://cdash.org', 'homeurl', 'https://cdash.org'], + ['homeurl', null, 'homeurl', null], + ['homeurl', 'https://cdash.org', 'homeUrl', 'https://cdash.org'], + ['homeurl', null, 'homeUrl', null], + ...$vcsViewerValues, + ['cvsurl', 'https://github.com/Kitware/CDash', 'vcsUrl', 'https://github.com/Kitware/CDash'], + ['cvsurl', null, 'vcsUrl', null], + ...$bugTrackerValues, + ['bugtrackerurl', 'https://github.com/Kitware/CDash/issues', 'bugTrackerUrl', 'https://github.com/Kitware/CDash/issues'], + ['bugtrackerurl', null, 'bugTrackerUrl', null], + ['bugtrackernewissueurl', 'https://github.com/Kitware/CDash/issues/new', 'bugTrackerNewIssueUrl', 'https://github.com/Kitware/CDash/issues/new'], + ['bugtrackernewissueurl', null, 'bugTrackerNewIssueUrl', null], + ['documentationurl', 'https://cmake.org/cmake/help/latest', 'documentationUrl', 'https://cmake.org/cmake/help/latest'], + ['documentationurl', null, 'documentationUrl', null], + ['testingdataurl', 'https://example.com', 'testDataUrl', 'https://example.com'], + ['testingdataurl', null, 'testDataUrl', null], + ['authenticatesubmissions', true, 'authenticateSubmissions', true], + ['authenticatesubmissions', false, 'authenticateSubmissions', false], + ['ldapfilter', '(uid=*group_1*)', 'ldapFilter', '(uid=*group_1*)'], + ['ldapfilter', null, 'ldapFilter', null], + ['coveragethreshold', 80, 'coverageThreshold', 80], + ['nightlytime', '00:00:00', 'nightlyTime', '00:00:00'], + ['nightlytime', '02:00:00 UTC', 'nightlyTime', '02:00:00 UTC'], + ['nightlytime', '22:00:00 EST', 'nightlyTime', '22:00:00 EST'], + ['nightlytime', '00:00:00 America/New_York', 'nightlyTime', '00:00:00 America/New_York'], + ['emaillowcoverage', true, 'emailLowCoverage', true], + ['emaillowcoverage', false, 'emailLowCoverage', false], + ['emailtesttimingchanged', true, 'emailTestTimingChanged', true], + ['emailtesttimingchanged', false, 'emailTestTimingChanged', false], + ['emailbrokensubmission', true, 'emailBrokenSubmissions', true], + ['emailbrokensubmission', false, 'emailBrokenSubmissions', false], + ['emailredundantfailures', true, 'emailRedundantFailures', true], + ['emailredundantfailures', false, 'emailRedundantFailures', false], + ['testtimestd', 10, 'testTimeStdMultiplier', 10], + ['testtimestdthreshold', 10, 'testTimeStdThreshold', 10], + ['showtesttime', true, 'enableTestTiming', true], + ['showtesttime', false, 'enableTestTiming', false], + ['testtimemaxstatus', 10, 'timeStatusFailureThreshold', 10], + ['emailmaxitems', 10, 'emailMaxItems', 10], + ['emailmaxchars', 10, 'emailMaxCharacters', 10], + ['displaylabels', true, 'displayLabels', true], + ['displaylabels', false, 'displayLabels', false], + ['autoremovemaxbuilds', 10, 'autoRemoveMaxBuilds', 10], + ['uploadquota', 10, 'fileUploadLimit', 10], + ['showcoveragecode', true, 'showCoverageCode', true], + ['showcoveragecode', false, 'showCoverageCode', false], + ['sharelabelfilters', true, 'shareLabelFilters', true], + ['sharelabelfilters', false, 'shareLabelFilters', false], + ['viewsubprojectslink', true, 'showViewSubProjectsLink', true], + ['viewsubprojectslink', false, 'showViewSubProjectsLink', false], + ['banner', 'test', 'banner', 'test'], + ['banner', null, 'banner', null], + ]; + } + + /** + * A basic test to ensure that each of the non-relationship fields works + */ + #[DataProvider('fieldValues')] + public function testBasicFieldAccess(string $modelField, mixed $modelValue, string $graphqlField, mixed $graphqlValue): void + { + $project = $this->makePublicProject(); + $project->setAttribute($modelField, $modelValue); + $project->save(); + $this->projects['test'] = $project; + + $this->graphQL(" + query project(\$id: ID) { + project(id: \$id) { + $graphqlField + } + } + ", [ + 'id' => $project->id, + ])->assertExactJson([ + 'data' => [ + 'project' => [ + $graphqlField => $graphqlValue, + ], + ], + ]); + } + /** * @return array{ * array{