Skip to content

Commit c0bdd12

Browse files
Add patch coverage API (#3742)
This PR adds a `CoverageDiff` GraphQL type and `createCoverageDiff` mutation which accepts two build IDs and creates a summary of the coverage diff between them. In the future, this patch coverage summary can be expanded by storing the actual diff, not just a summary of the diff. Another potential future improvement is to display this summary data in the UI somewhere. For now, this feature is available via the API only, and is meant for use in CI workflows.
1 parent 5d941ca commit c0bdd12

17 files changed

Lines changed: 1247 additions & 71 deletions
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\GraphQL\Mutations;
6+
7+
use App\Exceptions\GraphQLMutationException;
8+
use App\Jobs\ComputeCoverageDifference;
9+
use App\Models\Build;
10+
use App\Models\Project;
11+
use Illuminate\Support\Facades\Gate;
12+
use Illuminate\Support\Facades\Log;
13+
14+
final class CreateCoverageDiff extends AbstractMutation
15+
{
16+
/**
17+
* @param array{
18+
* baseBuildId: int,
19+
* compareBuildId: int,
20+
* } $args
21+
*
22+
* @throws GraphQLMutationException
23+
*/
24+
public function __invoke(null $_, array $args): self
25+
{
26+
$baseBuild = Build::find((int) $args['baseBuildId']);
27+
$compareBuild = Build::find((int) $args['compareBuildId']);
28+
29+
Gate::authorize('createCoverageDiff', $baseBuild->project ?? new Project());
30+
31+
if ($baseBuild === null || $compareBuild === null || $baseBuild->projectid !== $compareBuild->projectid) {
32+
throw new GraphQLMutationException('Builds must belong to the same project.');
33+
}
34+
35+
ComputeCoverageDifference::dispatch($baseBuild, $compareBuild);
36+
37+
Log::info('User ' . auth()->id() . " queued coverage diff for builds {$baseBuild->id} and {$compareBuild->id}.");
38+
39+
return $this;
40+
}
41+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\GraphQL\Validators;
6+
7+
use Nuwave\Lighthouse\Validation\Validator;
8+
9+
final class CreateCoverageDiffInputValidator extends Validator
10+
{
11+
/**
12+
* @return array<string, array<int, mixed>>
13+
*/
14+
public function rules(): array
15+
{
16+
return [
17+
'baseBuildId' => [
18+
'required',
19+
],
20+
'compareBuildId' => [
21+
'required',
22+
'different:baseBuildId',
23+
],
24+
];
25+
}
26+
27+
/**
28+
* @return array<string, string>
29+
*/
30+
public function messages(): array
31+
{
32+
return [
33+
'compareBuildId.different' => 'The base and compare builds must be different.',
34+
];
35+
}
36+
}
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
<?php
2+
3+
namespace App\Jobs;
4+
5+
use App\Models\Build;
6+
use App\Models\CoverageDiff;
7+
use App\Models\CoverageLine;
8+
use App\Models\CoverageView;
9+
use Exception;
10+
use Illuminate\Bus\Queueable;
11+
use Illuminate\Contracts\Queue\ShouldQueue;
12+
use Illuminate\Foundation\Bus\Dispatchable;
13+
use Illuminate\Queue\InteractsWithQueue;
14+
use Illuminate\Support\Collection;
15+
use InvalidArgumentException;
16+
use SebastianBergmann\Diff\Differ;
17+
use SebastianBergmann\Diff\Output\UnifiedDiffOutputBuilder;
18+
19+
class ComputeCoverageDifference implements ShouldQueue
20+
{
21+
use Dispatchable;
22+
use InteractsWithQueue;
23+
use Queueable;
24+
25+
public function __construct(
26+
public Build $baseBuild,
27+
public Build $compareBuild,
28+
) {
29+
if ($this->baseBuild->projectid !== $this->compareBuild->projectid) {
30+
throw new InvalidArgumentException('Builds must belong to the same project.');
31+
}
32+
}
33+
34+
/**
35+
* Execute the job.
36+
*/
37+
public function handle(): void
38+
{
39+
$basePaths = $this->baseBuild->coverage()->pluck('fullpath');
40+
$comparePaths = $this->compareBuild->coverage()->pluck('fullpath');
41+
/** @var Collection<int, string> $allPaths */
42+
$allPaths = $basePaths->merge($comparePaths)->unique()->sort()->values();
43+
44+
$coverageDiff = CoverageDiff::updateOrCreate(
45+
[
46+
'basebuildid' => $this->baseBuild->id,
47+
'comparebuildid' => $this->compareBuild->id,
48+
],
49+
[
50+
'coveredlinesadded' => 0,
51+
'coveredlinesremoved' => 0,
52+
'coveredlinesuncovered' => 0,
53+
'uncoveredlinesadded' => 0,
54+
'uncoveredlinesremoved' => 0,
55+
'uncoveredlinescovered' => 0,
56+
]
57+
);
58+
59+
foreach ($allPaths->chunk(100) as $batch) {
60+
$baseViews = $this->baseBuild->coverage()
61+
->whereIn('fullpath', $batch)
62+
->get()
63+
->keyBy('fullpath');
64+
$compareViews = $this->compareBuild->coverage()
65+
->whereIn('fullpath', $batch)
66+
->get()
67+
->keyBy('fullpath');
68+
69+
foreach ($batch as $path) {
70+
/** @var CoverageView|null $baseView */
71+
$baseView = $baseViews->get((string) $path);
72+
/** @var CoverageView|null $compareView */
73+
$compareView = $compareViews->get((string) $path);
74+
$this->computeFileDiff($baseView, $compareView, $coverageDiff);
75+
}
76+
}
77+
78+
$coverageDiff->save();
79+
}
80+
81+
/**
82+
* Compute the coverage diff between two coverage files.
83+
*/
84+
private function computeFileDiff(?CoverageView $cv1, ?CoverageView $cv2, CoverageDiff $coverageDiff): void
85+
{
86+
if ($cv1 === null && $cv2 === null) {
87+
return;
88+
}
89+
90+
if ($cv1 === null) {
91+
// File added in compare build
92+
if ($cv2 !== null) {
93+
foreach ($cv2->coveredLines as $line) {
94+
if ($line->isCovered) {
95+
$coverageDiff->coveredlinesadded++;
96+
} else {
97+
$coverageDiff->uncoveredlinesadded++;
98+
}
99+
}
100+
}
101+
return;
102+
}
103+
104+
if ($cv2 === null) {
105+
// File removed in compare build
106+
foreach ($cv1->coveredLines as $line) {
107+
if ($line->isCovered) {
108+
$coverageDiff->coveredlinesremoved++;
109+
} else {
110+
$coverageDiff->uncoveredlinesremoved++;
111+
}
112+
}
113+
return;
114+
}
115+
116+
/** @var Collection<int, CoverageLine> $cl1 */
117+
$cl1 = collect($cv1->coveredLines)->keyBy('lineNumber');
118+
/** @var Collection<int, CoverageLine> $cl2 */
119+
$cl2 = collect($cv2->coveredLines)->keyBy('lineNumber');
120+
121+
$differ = new Differ(new UnifiedDiffOutputBuilder());
122+
123+
// Files are 1-indexed, with the first line being line 1.
124+
$file1Line = 1;
125+
$file2Line = 1;
126+
foreach ($differ->diffToArray($cv1->file ?? '', $cv2->file ?? '') as [$lineText, $status]) {
127+
$line1 = $cl1->get($file1Line);
128+
$line2 = $cl2->get($file2Line);
129+
130+
switch ($status) {
131+
case Differ::OLD: // Line is shared between both files
132+
if ($line1 !== null && $line1->isCovered && $line2 !== null && !$line2->isCovered) {
133+
$coverageDiff->coveredlinesuncovered++;
134+
} elseif ($line1 !== null && !$line1->isCovered && $line2 !== null && $line2->isCovered) {
135+
$coverageDiff->uncoveredlinescovered++;
136+
} elseif ($line1 !== null && $line2 === null) {
137+
// We consider a line to be added if it was previously not executable, and now is executable.
138+
// The reverse is also true: a line is removed if it was previously executable, and now is not executable.
139+
if ($line1->isCovered) {
140+
$coverageDiff->coveredlinesremoved++;
141+
} else {
142+
$coverageDiff->uncoveredlinesremoved++;
143+
}
144+
} elseif ($line1 === null && $line2 !== null) {
145+
if ($line2->isCovered) {
146+
$coverageDiff->coveredlinesadded++;
147+
} else {
148+
$coverageDiff->uncoveredlinesadded++;
149+
}
150+
}
151+
152+
$file1Line++;
153+
$file2Line++;
154+
break;
155+
case Differ::ADDED: // Line exists in file 2 but not file 1
156+
if ($line2 !== null) {
157+
if ($line2->isCovered) {
158+
$coverageDiff->coveredlinesadded++;
159+
} else {
160+
$coverageDiff->uncoveredlinesadded++;
161+
}
162+
}
163+
164+
$file2Line++;
165+
break;
166+
case Differ::REMOVED: // Line exists in file 1 but not file 2
167+
if ($line1 !== null) {
168+
if ($line1->isCovered) {
169+
$coverageDiff->coveredlinesremoved++;
170+
} else {
171+
$coverageDiff->uncoveredlinesremoved++;
172+
}
173+
}
174+
175+
$file1Line++;
176+
break;
177+
default:
178+
throw new Exception('Invalid Differ status: ' . $status);
179+
}
180+
}
181+
}
182+
}

app/Models/Build.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,14 @@ public function coverage(): HasMany
273273
return $this->hasMany(CoverageView::class, 'buildid');
274274
}
275275

276+
/**
277+
* @return HasMany<CoverageDiff, $this>
278+
*/
279+
public function coverageDiffs(): HasMany
280+
{
281+
return $this->hasMany(CoverageDiff::class, 'comparebuildid');
282+
}
283+
276284
/**
277285
* @return BelongsToMany<Label, $this>
278286
*/

app/Models/CoverageDiff.php

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
<?php
2+
3+
namespace App\Models;
4+
5+
use Illuminate\Database\Eloquent\Builder;
6+
use Illuminate\Database\Eloquent\Model;
7+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
8+
9+
/**
10+
* @property int $id
11+
* @property int $basebuildid
12+
* @property int $comparebuildid
13+
* @property int $coveredlinesadded
14+
* @property int $coveredlinesremoved
15+
* @property int $coveredlinesuncovered
16+
* @property int $uncoveredlinesadded
17+
* @property int $uncoveredlinesremoved
18+
* @property int $uncoveredlinescovered
19+
*
20+
* @mixin Builder<CoverageDiff>
21+
*/
22+
class CoverageDiff extends Model
23+
{
24+
protected $table = 'coveragediff';
25+
26+
public $timestamps = false;
27+
28+
protected $fillable = [
29+
'basebuildid',
30+
'comparebuildid',
31+
'coveredlinesadded',
32+
'coveredlinesremoved',
33+
'coveredlinesuncovered',
34+
'uncoveredlinesadded',
35+
'uncoveredlinesremoved',
36+
'uncoveredlinescovered',
37+
];
38+
39+
protected $casts = [
40+
'id' => 'integer',
41+
'basebuildid' => 'integer',
42+
'comparebuildid' => 'integer',
43+
'coveredlinesadded' => 'integer',
44+
'coveredlinesremoved' => 'integer',
45+
'coveredlinesuncovered' => 'integer',
46+
'uncoveredlinesadded' => 'integer',
47+
'uncoveredlinesremoved' => 'integer',
48+
'uncoveredlinescovered' => 'integer',
49+
];
50+
51+
/**
52+
* @return BelongsTo<Build, $this>
53+
*/
54+
public function baseBuild(): BelongsTo
55+
{
56+
return $this->belongsTo(Build::class, 'basebuildid');
57+
}
58+
59+
/**
60+
* @return BelongsTo<Build, $this>
61+
*/
62+
public function compareBuild(): BelongsTo
63+
{
64+
return $this->belongsTo(Build::class, 'comparebuildid');
65+
}
66+
}

app/Models/CoverageLine.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,14 @@
44

55
readonly class CoverageLine
66
{
7+
public bool $isCovered;
8+
79
public function __construct(
810
public int $lineNumber,
911
public ?int $timesHit = null,
1012
public ?int $totalBranches = null,
1113
public ?int $branchesHit = null,
1214
) {
15+
$this->isCovered = ($this->timesHit !== null && $this->timesHit > 0) || ($this->branchesHit !== null && $this->branchesHit > 0);
1316
}
1417
}

app/Models/CoverageView.php

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
* @property ?string $fullpath
2929
* @property ?string $file
3030
* @property ?string $log
31+
* @property CoverageLine[] $coveredLines
3132
*
3233
* @mixin Builder<CoverageView>
3334
*/
@@ -64,12 +65,17 @@ protected function coveredLines(): Attribute
6465
{
6566
return Attribute::make(
6667
get: function (mixed $value, array $attributes): array {
67-
if ($attributes['log'] === null) {
68+
if (($attributes['log'] ?? null) === null) {
69+
return [];
70+
}
71+
72+
$log = str($attributes['log'])->rtrim(';');
73+
if ($log->isEmpty()) {
6874
return [];
6975
}
7076

7177
$lines = [];
72-
foreach (str($attributes['log'])->rtrim(';')->explode(';') as $line_str) {
78+
foreach ($log->explode(';') as $line_str) {
7379
$line_str = str($line_str);
7480

7581
$hasBranches = $line_str->startsWith('b');

app/Policies/ProjectPolicy.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,11 @@ public function createAuthToken(User $currentUser, Project $project): bool
171171
return $currentUser->admin || $project->users()->where('id', $currentUser->id)->exists();
172172
}
173173

174+
public function createCoverageDiff(User $user, Project $project): bool
175+
{
176+
return $user->admin || $project->users()->where('id', $user->id)->exists();
177+
}
178+
174179
private function isLdapControlledMembership(Project $project): bool
175180
{
176181
// If a LDAP filter has been specified and LDAP is enabled, CDash controls the entire members list.

0 commit comments

Comments
 (0)