1077 lines
46 KiB
PHP
1077 lines
46 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Support\ReviewPublicationResolution;
|
|
|
|
use App\Models\EnvironmentReview;
|
|
use App\Models\EvidenceSnapshot;
|
|
use App\Models\EvidenceSnapshotItem;
|
|
use App\Models\OperationRun;
|
|
use App\Models\ReviewPack;
|
|
use App\Models\ReviewPublicationResolutionStep;
|
|
use App\Models\StoredReport;
|
|
use App\Services\ReviewPackService;
|
|
use App\Support\Evidence\EvidenceCompletenessState;
|
|
use App\Support\Evidence\EvidenceSnapshotStatus;
|
|
use App\Support\OperationRunOutcome;
|
|
use App\Support\OperationRunStatus;
|
|
use App\Support\OperationRunType;
|
|
use App\Support\ReviewPackStatus;
|
|
use Carbon\CarbonInterface;
|
|
use Illuminate\Support\Collection;
|
|
|
|
final class ReviewPublicationProofResolver
|
|
{
|
|
private const array REPORT_DIMENSION_TYPES = [
|
|
'permission_posture' => StoredReport::REPORT_TYPE_PERMISSION_POSTURE,
|
|
'entra_admin_roles' => StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES,
|
|
];
|
|
|
|
private const string PROVIDER_CONNECTION_CHECK_OPERATION_TYPE = 'provider.connection.check';
|
|
|
|
public function __construct(
|
|
private readonly ReviewPackService $reviewPacks,
|
|
) {}
|
|
|
|
/**
|
|
* @return array{
|
|
* proof_type:?string,
|
|
* proof_id:?int,
|
|
* proof_status:?string,
|
|
* operation_run_id:?int,
|
|
* proof_currentness:string,
|
|
* proof_usability:string,
|
|
* proof_visibility:string,
|
|
* proof_reason_code:string,
|
|
* proof_evaluated_at:?string,
|
|
* proof_timestamp:?string,
|
|
* proof_summary:array<string, mixed>
|
|
* }
|
|
*/
|
|
public function proofFor(
|
|
ReviewPublicationResolutionStepKey $stepKey,
|
|
EnvironmentReview $review,
|
|
array $readiness = [],
|
|
?ReviewPublicationResolutionStep $existingStep = null,
|
|
): array {
|
|
return $this->evaluationFor($stepKey, $review, $readiness, $existingStep)->toStepPayload();
|
|
}
|
|
|
|
public function evaluationFor(
|
|
ReviewPublicationResolutionStepKey $stepKey,
|
|
EnvironmentReview $review,
|
|
array $readiness = [],
|
|
?ReviewPublicationResolutionStep $existingStep = null,
|
|
): ResolutionProofEvaluation {
|
|
$review->loadMissing([
|
|
'evidenceSnapshot.items',
|
|
'currentExportReviewPack.operationRun',
|
|
'operationRun',
|
|
]);
|
|
|
|
return match ($stepKey) {
|
|
ReviewPublicationResolutionStepKey::ValidateReviewReadiness => $this->reviewProof(
|
|
stepKey: $stepKey,
|
|
review: $review,
|
|
readiness: $readiness,
|
|
usable: true,
|
|
reasonCode: 'proof.review_readiness_evaluated',
|
|
),
|
|
ReviewPublicationResolutionStepKey::CompleteRequiredReports => $this->requiredReportsProof($review, $readiness, $existingStep),
|
|
ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot => $this->evidenceProof($review, $readiness, $existingStep),
|
|
ReviewPublicationResolutionStepKey::RefreshReviewComposition => $this->reviewOutputProof($stepKey, $review, $readiness, $existingStep),
|
|
ReviewPublicationResolutionStepKey::ReturnToPublication => $this->reviewProof(
|
|
stepKey: $stepKey,
|
|
review: $review,
|
|
readiness: $readiness,
|
|
usable: ! (bool) ($readiness['review_requires_refresh'] ?? false),
|
|
reasonCode: ! (bool) ($readiness['review_requires_refresh'] ?? false)
|
|
? 'proof.review_output_current'
|
|
: 'proof.review_output_stale',
|
|
),
|
|
ReviewPublicationResolutionStepKey::GenerateReviewPack => $this->reviewPackProof($review, $readiness, $existingStep),
|
|
};
|
|
}
|
|
|
|
public function evidenceProof(
|
|
EnvironmentReview|EvidenceSnapshot|null $subject,
|
|
array $readiness = [],
|
|
?ReviewPublicationResolutionStep $existingStep = null,
|
|
): ResolutionProofEvaluation|array {
|
|
if ($subject instanceof EnvironmentReview) {
|
|
$review = $subject;
|
|
$snapshot = $review->evidenceSnapshot;
|
|
} else {
|
|
$review = null;
|
|
$snapshot = $subject;
|
|
}
|
|
|
|
if (! $snapshot instanceof EvidenceSnapshot) {
|
|
if ($review instanceof EnvironmentReview && $existingStep instanceof ReviewPublicationResolutionStep) {
|
|
return $this->operationProofOrMissing($existingStep, $review, $readiness, 'proof.evidence_missing');
|
|
}
|
|
|
|
$missing = ResolutionProofEvaluation::missing(
|
|
actionKey: ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot,
|
|
review: $review ?? new EnvironmentReview,
|
|
reasonCode: 'proof.evidence_missing',
|
|
);
|
|
|
|
return $review instanceof EnvironmentReview ? $missing : $missing->toStepPayload();
|
|
}
|
|
|
|
if (! $review instanceof EnvironmentReview) {
|
|
return [
|
|
'proof_type' => 'evidence_snapshot',
|
|
'proof_id' => (int) $snapshot->getKey(),
|
|
'proof_status' => (string) $snapshot->status,
|
|
'operation_run_id' => is_numeric($snapshot->operation_run_id) ? (int) $snapshot->operation_run_id : null,
|
|
];
|
|
}
|
|
|
|
if (! $this->sameReviewScope($review, $snapshot)) {
|
|
return $this->notAccessible($review, ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot, 'proof.evidence_scope_mismatch');
|
|
}
|
|
|
|
$latestReports = $this->latestRequiredReports($review);
|
|
$staleDimensions = $this->staleSnapshotDimensions($snapshot, $latestReports);
|
|
$missingCurrentReportReferenceDimensions = $this->missingCurrentReportReferenceDimensions($snapshot, $latestReports);
|
|
$staleOrMissingCurrentReportReferenceDimensions = array_values(array_unique(array_merge(
|
|
$staleDimensions,
|
|
$missingCurrentReportReferenceDimensions,
|
|
)));
|
|
$unavailableReportDimensions = $this->unavailableRequiredReportDimensions($latestReports);
|
|
$snapshotComplete = (string) $snapshot->status === EvidenceSnapshotStatus::Active->value
|
|
&& (string) $snapshot->completeness_state === EvidenceCompletenessState::Complete->value
|
|
&& (int) data_get($snapshot->summary, 'missing_dimensions', 0) === 0
|
|
&& (int) data_get($snapshot->summary, 'stale_dimensions', 0) === 0;
|
|
$usable = $snapshotComplete
|
|
&& $staleOrMissingCurrentReportReferenceDimensions === []
|
|
&& $unavailableReportDimensions === [];
|
|
$evaluation = new ResolutionProofEvaluation(
|
|
actionKey: ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot,
|
|
subjectType: EnvironmentReview::class,
|
|
subjectId: (int) $review->getKey(),
|
|
status: $this->artifactStatus((string) $snapshot->status, [
|
|
EvidenceSnapshotStatus::Active->value => ResolutionProofStatus::Available,
|
|
EvidenceSnapshotStatus::Queued->value => ResolutionProofStatus::Running,
|
|
EvidenceSnapshotStatus::Generating->value => ResolutionProofStatus::Running,
|
|
EvidenceSnapshotStatus::Failed->value => ResolutionProofStatus::Failed,
|
|
EvidenceSnapshotStatus::Superseded->value => ResolutionProofStatus::Unavailable,
|
|
EvidenceSnapshotStatus::Expired->value => ResolutionProofStatus::Unavailable,
|
|
]),
|
|
currentness: $usable ? ResolutionProofCurrentness::Current : ResolutionProofCurrentness::Stale,
|
|
usability: $usable ? ResolutionProofUsability::Usable : ResolutionProofUsability::NotUsable,
|
|
visibility: ResolutionProofVisibility::OperatorVisible,
|
|
reasonCode: $usable ? 'proof.evidence_current' : 'proof.evidence_stale',
|
|
reference: new ResolutionProofReference(
|
|
proofType: 'evidence_snapshot',
|
|
proofId: (int) $snapshot->getKey(),
|
|
sourceStatus: (string) $snapshot->status,
|
|
proofTimestamp: $snapshot->generated_at ?? $snapshot->updated_at,
|
|
),
|
|
operationRunId: is_numeric($snapshot->operation_run_id) ? (int) $snapshot->operation_run_id : null,
|
|
evaluatedAt: now(),
|
|
safeSummary: [
|
|
'label' => $usable ? 'Current evidence proof' : 'Outdated evidence proof',
|
|
'stale_dimensions' => $staleOrMissingCurrentReportReferenceDimensions,
|
|
'missing_current_report_reference_dimensions' => $missingCurrentReportReferenceDimensions,
|
|
'unavailable_report_dimensions' => $unavailableReportDimensions,
|
|
'evidence_state' => (string) $snapshot->completeness_state,
|
|
],
|
|
);
|
|
|
|
if (! $evaluation->canCompleteStep() && $existingStep instanceof ReviewPublicationResolutionStep) {
|
|
return $this->operationProofOrFallback($existingStep, $review, $readiness, $evaluation);
|
|
}
|
|
|
|
return $evaluation;
|
|
}
|
|
|
|
public function reviewPackProof(
|
|
EnvironmentReview|ReviewPack|null $subject,
|
|
array $readiness = [],
|
|
?ReviewPublicationResolutionStep $existingStep = null,
|
|
): ResolutionProofEvaluation|array {
|
|
if ($subject instanceof EnvironmentReview) {
|
|
$review = $subject;
|
|
$reviewPack = $review->currentExportReviewPack;
|
|
} else {
|
|
$review = null;
|
|
$reviewPack = $subject;
|
|
}
|
|
|
|
if (! $reviewPack instanceof ReviewPack) {
|
|
if ($review instanceof EnvironmentReview && $existingStep instanceof ReviewPublicationResolutionStep) {
|
|
return $this->operationProofOrMissing($existingStep, $review, $readiness, 'proof.review_pack_missing');
|
|
}
|
|
|
|
$missing = ResolutionProofEvaluation::missing(
|
|
actionKey: ReviewPublicationResolutionStepKey::GenerateReviewPack,
|
|
review: $review ?? new EnvironmentReview,
|
|
reasonCode: 'proof.review_pack_missing',
|
|
);
|
|
|
|
return $review instanceof EnvironmentReview ? $missing : $missing->toStepPayload();
|
|
}
|
|
|
|
if (! $review instanceof EnvironmentReview) {
|
|
return [
|
|
'proof_type' => 'review_pack',
|
|
'proof_id' => (int) $reviewPack->getKey(),
|
|
'proof_status' => (string) $reviewPack->status,
|
|
'operation_run_id' => is_numeric($reviewPack->operation_run_id) ? (int) $reviewPack->operation_run_id : null,
|
|
];
|
|
}
|
|
|
|
if (! $this->sameReviewScope($review, $reviewPack)
|
|
|| (int) $reviewPack->environment_review_id !== (int) $review->getKey()) {
|
|
return $this->notAccessible($review, ReviewPublicationResolutionStepKey::GenerateReviewPack, 'proof.review_pack_scope_mismatch');
|
|
}
|
|
|
|
$packMatchesCurrentReviewOutput = $this->reviewPackMatchesCurrentOutput($review, $reviewPack);
|
|
$usable = (string) $reviewPack->status === ReviewPackStatus::Ready->value
|
|
&& (bool) ($readiness['has_ready_export'] ?? false)
|
|
&& (int) $review->current_export_review_pack_id === (int) $reviewPack->getKey()
|
|
&& filled($reviewPack->file_path)
|
|
&& filled($reviewPack->file_disk)
|
|
&& ($reviewPack->expires_at === null || ! $reviewPack->expires_at->isPast())
|
|
&& $packMatchesCurrentReviewOutput;
|
|
|
|
$evaluation = new ResolutionProofEvaluation(
|
|
actionKey: ReviewPublicationResolutionStepKey::GenerateReviewPack,
|
|
subjectType: EnvironmentReview::class,
|
|
subjectId: (int) $review->getKey(),
|
|
status: $this->artifactStatus((string) $reviewPack->status, [
|
|
ReviewPackStatus::Ready->value => ResolutionProofStatus::Available,
|
|
ReviewPackStatus::Queued->value => ResolutionProofStatus::Running,
|
|
ReviewPackStatus::Generating->value => ResolutionProofStatus::Running,
|
|
ReviewPackStatus::Failed->value => ResolutionProofStatus::Failed,
|
|
ReviewPackStatus::Expired->value => ResolutionProofStatus::Unavailable,
|
|
]),
|
|
currentness: $usable ? ResolutionProofCurrentness::Current : ResolutionProofCurrentness::Stale,
|
|
usability: $usable ? ResolutionProofUsability::Usable : ResolutionProofUsability::NotUsable,
|
|
visibility: ResolutionProofVisibility::OperatorVisible,
|
|
reasonCode: $usable ? 'proof.review_pack_current' : 'proof.review_pack_stale',
|
|
reference: new ResolutionProofReference(
|
|
proofType: 'review_pack',
|
|
proofId: (int) $reviewPack->getKey(),
|
|
sourceStatus: (string) $reviewPack->status,
|
|
proofTimestamp: $reviewPack->generated_at ?? $reviewPack->updated_at,
|
|
),
|
|
operationRunId: is_numeric($reviewPack->operation_run_id) ? (int) $reviewPack->operation_run_id : null,
|
|
evaluatedAt: now(),
|
|
safeSummary: [
|
|
'label' => $usable ? 'Current review-pack proof' : 'Outdated review-pack proof',
|
|
],
|
|
);
|
|
|
|
if (! $evaluation->canCompleteStep() && $existingStep instanceof ReviewPublicationResolutionStep) {
|
|
return $this->operationProofOrFallback($existingStep, $review, $readiness, $evaluation);
|
|
}
|
|
|
|
return $evaluation;
|
|
}
|
|
|
|
private function requiredReportsProof(
|
|
EnvironmentReview $review,
|
|
array $readiness,
|
|
?ReviewPublicationResolutionStep $existingStep,
|
|
): ResolutionProofEvaluation {
|
|
$latestReports = $this->latestRequiredReports($review);
|
|
$snapshot = $review->evidenceSnapshot;
|
|
$missingDimensions = collect((array) ($readiness['missing_report_dimensions'] ?? []))
|
|
->filter(static fn (mixed $dimension): bool => is_string($dimension) && array_key_exists($dimension, self::REPORT_DIMENSION_TYPES))
|
|
->values()
|
|
->all();
|
|
$staleDimensions = [];
|
|
$proofReport = null;
|
|
$operationRun = $existingStep?->operationRun;
|
|
$directOperationRunId = $this->supersededOperationRunId($existingStep);
|
|
$supersededRun = $this->validSupersededOperationRunId($existingStep, $review);
|
|
$previouslySupersededRun = $this->validPreviouslySupersededOperationRunId($existingStep, $review);
|
|
$previouslySupersededOperationRun = $previouslySupersededRun !== null
|
|
? OperationRun::query()->find($previouslySupersededRun)
|
|
: null;
|
|
$operationRunForSupersede = $operationRun instanceof OperationRun
|
|
? $operationRun
|
|
: ($previouslySupersededOperationRun instanceof OperationRun ? $previouslySupersededOperationRun : null);
|
|
$canUseSupersedingReport = $supersededRun !== null || $previouslySupersededRun !== null;
|
|
$operationSupersededByReport = false;
|
|
|
|
if ($directOperationRunId !== null && $supersededRun === null && $this->shouldRejectDirectOperationBeforeArtifactProof($existingStep, $review)) {
|
|
return $this->operationProofOrFallback(
|
|
existingStep: $existingStep,
|
|
review: $review,
|
|
readiness: $readiness,
|
|
fallback: ResolutionProofEvaluation::missing(
|
|
actionKey: ReviewPublicationResolutionStepKey::CompleteRequiredReports,
|
|
review: $review,
|
|
reasonCode: 'proof.required_report_missing',
|
|
),
|
|
);
|
|
}
|
|
|
|
foreach (self::REPORT_DIMENSION_TYPES as $dimension => $reportType) {
|
|
$report = $latestReports->get($dimension);
|
|
$item = $snapshot instanceof EvidenceSnapshot
|
|
? $snapshot->items->firstWhere('dimension_key', $dimension)
|
|
: null;
|
|
$itemComplete = $item instanceof EvidenceSnapshotItem
|
|
&& (string) $item->state === EvidenceCompletenessState::Complete->value;
|
|
|
|
if (! $report instanceof StoredReport || (string) $report->status !== StoredReport::STATUS_READY) {
|
|
$missingDimensions[] = $dimension;
|
|
|
|
continue;
|
|
}
|
|
|
|
$proofReport = $report;
|
|
|
|
$dimensionRequiresReportAction = in_array($dimension, $missingDimensions, true);
|
|
$hasCurrentProof = $itemComplete && is_numeric($item->source_record_id) && (int) $item->source_record_id === (int) $report->getKey();
|
|
$hasSupersedingProof = $canUseSupersedingReport
|
|
&& $this->reportCanSupersedeOperation($report, $operationRunForSupersede);
|
|
$hasReadyReportProof = ! $dimensionRequiresReportAction;
|
|
|
|
if ($hasSupersedingProof) {
|
|
$operationSupersededByReport = true;
|
|
}
|
|
|
|
if ($hasCurrentProof || $hasSupersedingProof || $hasReadyReportProof) {
|
|
$missingDimensions = array_values(array_diff($missingDimensions, [$dimension]));
|
|
|
|
continue;
|
|
}
|
|
|
|
$staleDimensions[] = $dimension;
|
|
}
|
|
|
|
$missingDimensions = array_values(array_unique($missingDimensions));
|
|
$staleDimensions = array_values(array_unique($staleDimensions));
|
|
|
|
if ($missingDimensions !== [] || $staleDimensions !== [] || ! $proofReport instanceof StoredReport) {
|
|
$fallback = ResolutionProofEvaluation::missing(
|
|
actionKey: ReviewPublicationResolutionStepKey::CompleteRequiredReports,
|
|
review: $review,
|
|
reasonCode: $missingDimensions !== [] ? 'proof.required_report_missing' : 'proof.required_report_stale',
|
|
);
|
|
|
|
$fallback = new ResolutionProofEvaluation(
|
|
actionKey: ReviewPublicationResolutionStepKey::CompleteRequiredReports,
|
|
subjectType: EnvironmentReview::class,
|
|
subjectId: (int) $review->getKey(),
|
|
status: $missingDimensions !== [] ? ResolutionProofStatus::Missing : ResolutionProofStatus::Available,
|
|
currentness: $staleDimensions !== [] ? ResolutionProofCurrentness::Stale : ResolutionProofCurrentness::Unknown,
|
|
usability: ResolutionProofUsability::NotUsable,
|
|
visibility: ResolutionProofVisibility::OperatorVisible,
|
|
reasonCode: $fallback->reasonCode,
|
|
evaluatedAt: now(),
|
|
safeSummary: [
|
|
'missing_dimensions' => $missingDimensions,
|
|
'stale_dimensions' => $staleDimensions,
|
|
],
|
|
);
|
|
|
|
if ($existingStep instanceof ReviewPublicationResolutionStep) {
|
|
return $this->operationProofOrFallback($existingStep, $review, $readiness, $fallback);
|
|
}
|
|
|
|
return $fallback;
|
|
}
|
|
|
|
$supersededRun ??= $previouslySupersededRun;
|
|
$effectiveSupersededRun = $operationSupersededByReport ? $supersededRun : null;
|
|
|
|
return new ResolutionProofEvaluation(
|
|
actionKey: ReviewPublicationResolutionStepKey::CompleteRequiredReports,
|
|
subjectType: EnvironmentReview::class,
|
|
subjectId: (int) $review->getKey(),
|
|
status: ResolutionProofStatus::Available,
|
|
currentness: ResolutionProofCurrentness::Current,
|
|
usability: ResolutionProofUsability::Usable,
|
|
visibility: ResolutionProofVisibility::OperatorVisible,
|
|
reasonCode: $effectiveSupersededRun === null
|
|
? 'proof.required_reports_current'
|
|
: 'proof.required_reports_supersede_operation',
|
|
reference: new ResolutionProofReference(
|
|
proofType: 'stored_report',
|
|
proofId: (int) $proofReport->getKey(),
|
|
sourceStatus: (string) $proofReport->status,
|
|
proofTimestamp: $proofReport->generated_at ?? $proofReport->updated_at,
|
|
),
|
|
operationRunId: is_numeric($proofReport->operation_run_id) ? (int) $proofReport->operation_run_id : null,
|
|
evaluatedAt: now(),
|
|
safeSummary: [
|
|
'label' => $effectiveSupersededRun === null ? 'Current required-report proof' : 'Superseded by newer required-report proof',
|
|
'report_dimensions' => array_keys(self::REPORT_DIMENSION_TYPES),
|
|
'superseded_operation_run_id' => $effectiveSupersededRun,
|
|
],
|
|
);
|
|
}
|
|
|
|
private function reviewProof(
|
|
ReviewPublicationResolutionStepKey $stepKey,
|
|
EnvironmentReview $review,
|
|
array $readiness,
|
|
bool $usable,
|
|
string $reasonCode,
|
|
): ResolutionProofEvaluation {
|
|
return new ResolutionProofEvaluation(
|
|
actionKey: $stepKey,
|
|
subjectType: EnvironmentReview::class,
|
|
subjectId: (int) $review->getKey(),
|
|
status: ResolutionProofStatus::Available,
|
|
currentness: $usable ? ResolutionProofCurrentness::Current : ResolutionProofCurrentness::Stale,
|
|
usability: $usable ? ResolutionProofUsability::Usable : ResolutionProofUsability::NotUsable,
|
|
visibility: ResolutionProofVisibility::OperatorVisible,
|
|
reasonCode: $reasonCode,
|
|
reference: new ResolutionProofReference(
|
|
proofType: 'environment_review',
|
|
proofId: (int) $review->getKey(),
|
|
sourceStatus: (string) $review->status,
|
|
proofTimestamp: $review->generated_at ?? $review->updated_at,
|
|
),
|
|
operationRunId: is_numeric($review->operation_run_id) ? (int) $review->operation_run_id : null,
|
|
evaluatedAt: now(),
|
|
safeSummary: [
|
|
'label' => $usable ? 'Current review proof' : 'Outdated review proof',
|
|
'review_status' => (string) ($readiness['review_status'] ?? $review->status),
|
|
'review_completeness_state' => (string) ($readiness['review_completeness_state'] ?? $review->completeness_state),
|
|
],
|
|
);
|
|
}
|
|
|
|
private function reviewOutputProof(
|
|
ReviewPublicationResolutionStepKey $stepKey,
|
|
EnvironmentReview $review,
|
|
array $readiness,
|
|
?ReviewPublicationResolutionStep $existingStep,
|
|
): ResolutionProofEvaluation {
|
|
$evaluation = $this->reviewProof(
|
|
stepKey: $stepKey,
|
|
review: $review,
|
|
readiness: $readiness,
|
|
usable: ! (bool) ($readiness['review_requires_refresh'] ?? false),
|
|
reasonCode: ! (bool) ($readiness['review_requires_refresh'] ?? false)
|
|
? 'proof.review_output_current'
|
|
: 'proof.review_output_stale',
|
|
);
|
|
|
|
if (! $evaluation->canCompleteStep() && $existingStep instanceof ReviewPublicationResolutionStep) {
|
|
return $this->operationProofOrFallback($existingStep, $review, $readiness, $evaluation);
|
|
}
|
|
|
|
return $evaluation;
|
|
}
|
|
|
|
private function operationProofOrMissing(
|
|
ReviewPublicationResolutionStep $existingStep,
|
|
EnvironmentReview $review,
|
|
array $readiness,
|
|
string $missingReasonCode,
|
|
): ResolutionProofEvaluation {
|
|
$fallback = ResolutionProofEvaluation::missing($existingStep->stepKeyEnum() ?? ReviewPublicationResolutionStepKey::ValidateReviewReadiness, $review, $missingReasonCode);
|
|
|
|
return $this->operationProofOrFallback($existingStep, $review, $readiness, $fallback);
|
|
}
|
|
|
|
private function operationProofOrFallback(
|
|
ReviewPublicationResolutionStep $existingStep,
|
|
EnvironmentReview $review,
|
|
array $readiness,
|
|
ResolutionProofEvaluation $fallback,
|
|
): ResolutionProofEvaluation {
|
|
if (! in_array($existingStep->statusEnum(), [
|
|
ReviewPublicationResolutionStepStatus::Running,
|
|
ReviewPublicationResolutionStepStatus::Failed,
|
|
], true)) {
|
|
return $fallback;
|
|
}
|
|
|
|
$operationRun = $existingStep->operationRun;
|
|
|
|
if (! $operationRun instanceof OperationRun) {
|
|
return $fallback;
|
|
}
|
|
|
|
$stepKey = $existingStep->stepKeyEnum() ?? ReviewPublicationResolutionStepKey::ValidateReviewReadiness;
|
|
|
|
if (! $this->sameOperationScope($review, $operationRun)) {
|
|
return $this->notAccessible($review, $stepKey, 'proof.operation_scope_mismatch');
|
|
}
|
|
|
|
$operationMismatchReason = $this->operationMismatchReason($stepKey, $review, $existingStep, $operationRun);
|
|
|
|
if ($operationMismatchReason !== null) {
|
|
return $this->operationMismatch($review, $stepKey, $operationRun, $operationMismatchReason);
|
|
}
|
|
|
|
$hasMatchingReadinessFingerprint = (string) data_get($existingStep->metadata, 'readiness_fingerprint') === (string) ($readiness['fingerprint'] ?? null);
|
|
$hasSourceOwnedContext = $this->operationHasCurrentResolutionContext($stepKey, $review, $existingStep, $operationRun)
|
|
|| $this->operationSourceArtifactMatchesStep($stepKey, $review, $existingStep, $operationRun);
|
|
$currentness = $hasMatchingReadinessFingerprint || $hasSourceOwnedContext
|
|
? ResolutionProofCurrentness::Current
|
|
: ResolutionProofCurrentness::Stale;
|
|
|
|
if ((string) $operationRun->status !== OperationRunStatus::Completed->value) {
|
|
return new ResolutionProofEvaluation(
|
|
actionKey: $stepKey,
|
|
subjectType: EnvironmentReview::class,
|
|
subjectId: (int) $review->getKey(),
|
|
status: ResolutionProofStatus::Running,
|
|
currentness: $currentness,
|
|
usability: ResolutionProofUsability::InspectionOnly,
|
|
visibility: ResolutionProofVisibility::OperatorVisible,
|
|
reasonCode: $currentness === ResolutionProofCurrentness::Current
|
|
? 'proof.operation_running'
|
|
: 'proof.operation_running_stale',
|
|
reference: new ResolutionProofReference(
|
|
proofType: 'operation_run',
|
|
proofId: (int) $operationRun->getKey(),
|
|
sourceStatus: (string) $operationRun->status,
|
|
proofTimestamp: $operationRun->started_at ?? $operationRun->created_at,
|
|
),
|
|
operationRunId: (int) $operationRun->getKey(),
|
|
evaluatedAt: now(),
|
|
safeSummary: [
|
|
'label' => 'Operation running',
|
|
'operation_type' => (string) $operationRun->type,
|
|
],
|
|
);
|
|
}
|
|
|
|
$status = match ((string) $operationRun->outcome) {
|
|
OperationRunOutcome::Succeeded->value,
|
|
OperationRunOutcome::PartiallySucceeded->value => ResolutionProofStatus::Succeeded,
|
|
OperationRunOutcome::Cancelled->value => ResolutionProofStatus::Cancelled,
|
|
OperationRunOutcome::Failed->value,
|
|
OperationRunOutcome::Blocked->value => ResolutionProofStatus::Failed,
|
|
default => ResolutionProofStatus::Unknown,
|
|
};
|
|
|
|
return new ResolutionProofEvaluation(
|
|
actionKey: $stepKey,
|
|
subjectType: EnvironmentReview::class,
|
|
subjectId: (int) $review->getKey(),
|
|
status: $status,
|
|
currentness: $currentness,
|
|
usability: ResolutionProofUsability::InspectionOnly,
|
|
visibility: ResolutionProofVisibility::OperatorVisible,
|
|
reasonCode: $status === ResolutionProofStatus::Succeeded
|
|
? 'proof.operation_succeeded_without_artifact'
|
|
: 'proof.operation_terminal_without_current_artifact',
|
|
reference: new ResolutionProofReference(
|
|
proofType: 'operation_run',
|
|
proofId: (int) $operationRun->getKey(),
|
|
sourceStatus: (string) $operationRun->outcome,
|
|
proofTimestamp: $operationRun->completed_at ?? $operationRun->updated_at,
|
|
),
|
|
operationRunId: (int) $operationRun->getKey(),
|
|
evaluatedAt: now(),
|
|
safeSummary: [
|
|
'label' => $status === ResolutionProofStatus::Succeeded
|
|
? 'Operation finished but artifact proof is still missing'
|
|
: 'Action failed',
|
|
'operation_type' => (string) $operationRun->type,
|
|
],
|
|
);
|
|
}
|
|
|
|
private function notAccessible(EnvironmentReview $review, ReviewPublicationResolutionStepKey $stepKey, string $reasonCode): ResolutionProofEvaluation
|
|
{
|
|
return new ResolutionProofEvaluation(
|
|
actionKey: $stepKey,
|
|
subjectType: EnvironmentReview::class,
|
|
subjectId: (int) $review->getKey(),
|
|
status: ResolutionProofStatus::NotAccessible,
|
|
currentness: ResolutionProofCurrentness::Unknown,
|
|
usability: ResolutionProofUsability::NotUsable,
|
|
visibility: ResolutionProofVisibility::Hidden,
|
|
reasonCode: $reasonCode,
|
|
evaluatedAt: now(),
|
|
safeSummary: [
|
|
'message' => 'Proof is not available for the current workspace or environment.',
|
|
],
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return Collection<string, StoredReport>
|
|
*/
|
|
private function latestRequiredReports(EnvironmentReview $review): Collection
|
|
{
|
|
return StoredReport::query()
|
|
->where('workspace_id', (int) $review->workspace_id)
|
|
->where('managed_environment_id', (int) $review->managed_environment_id)
|
|
->whereIn('report_type', array_values(self::REPORT_DIMENSION_TYPES))
|
|
->orderByDesc('generated_at')
|
|
->orderByDesc('updated_at')
|
|
->orderByDesc('id')
|
|
->get()
|
|
->unique('report_type')
|
|
->mapWithKeys(function (StoredReport $report): array {
|
|
$dimension = array_search((string) $report->report_type, self::REPORT_DIMENSION_TYPES, true);
|
|
|
|
return is_string($dimension) ? [$dimension => $report] : [];
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @param Collection<string, StoredReport> $latestReports
|
|
* @return list<string>
|
|
*/
|
|
private function staleSnapshotDimensions(EvidenceSnapshot $snapshot, Collection $latestReports): array
|
|
{
|
|
$stale = [];
|
|
|
|
foreach (self::REPORT_DIMENSION_TYPES as $dimension => $reportType) {
|
|
$report = $latestReports->get($dimension);
|
|
$item = $snapshot->items->firstWhere('dimension_key', $dimension);
|
|
|
|
if (! $report instanceof StoredReport || ! $item instanceof EvidenceSnapshotItem) {
|
|
continue;
|
|
}
|
|
|
|
if ((string) $item->state !== EvidenceCompletenessState::Complete->value || ! is_numeric($item->source_record_id)) {
|
|
continue;
|
|
}
|
|
|
|
if ((string) $report->status !== StoredReport::STATUS_READY) {
|
|
$stale[] = $dimension;
|
|
|
|
continue;
|
|
}
|
|
|
|
if ((int) $item->source_record_id !== (int) $report->getKey()
|
|
&& $this->reportIsNewerThanSnapshot($report, $snapshot, $item->source_record_id)) {
|
|
$stale[] = $dimension;
|
|
}
|
|
}
|
|
|
|
return $stale;
|
|
}
|
|
|
|
/**
|
|
* @param Collection<string, StoredReport> $latestReports
|
|
* @return list<string>
|
|
*/
|
|
private function unavailableRequiredReportDimensions(Collection $latestReports): array
|
|
{
|
|
$unavailable = [];
|
|
|
|
foreach (self::REPORT_DIMENSION_TYPES as $dimension => $reportType) {
|
|
$report = $latestReports->get($dimension);
|
|
|
|
if (! $report instanceof StoredReport || (string) $report->status !== StoredReport::STATUS_READY) {
|
|
$unavailable[] = $dimension;
|
|
}
|
|
}
|
|
|
|
return $unavailable;
|
|
}
|
|
|
|
/**
|
|
* @param Collection<string, StoredReport> $latestReports
|
|
* @return list<string>
|
|
*/
|
|
private function missingCurrentReportReferenceDimensions(EvidenceSnapshot $snapshot, Collection $latestReports): array
|
|
{
|
|
$missing = [];
|
|
|
|
foreach (self::REPORT_DIMENSION_TYPES as $dimension => $reportType) {
|
|
$report = $latestReports->get($dimension);
|
|
|
|
if (! $report instanceof StoredReport || (string) $report->status !== StoredReport::STATUS_READY) {
|
|
continue;
|
|
}
|
|
|
|
$item = $snapshot->items->firstWhere('dimension_key', $dimension);
|
|
|
|
if (! $item instanceof EvidenceSnapshotItem
|
|
|| (string) $item->state !== EvidenceCompletenessState::Complete->value
|
|
|| ! is_numeric($item->source_record_id)
|
|
|| (int) $item->source_record_id !== (int) $report->getKey()) {
|
|
$missing[] = $dimension;
|
|
}
|
|
}
|
|
|
|
return $missing;
|
|
}
|
|
|
|
private function reportIsNewerThanSnapshot(StoredReport $report, ?EvidenceSnapshot $snapshot, mixed $itemSourceRecordId): bool
|
|
{
|
|
if ($snapshot === null) {
|
|
return true;
|
|
}
|
|
|
|
if (is_numeric($itemSourceRecordId) && (int) $itemSourceRecordId === (int) $report->getKey()) {
|
|
return false;
|
|
}
|
|
|
|
$reportTimestamp = $this->latestTimestamp($report->generated_at, $report->updated_at, $report->created_at);
|
|
$snapshotTimestamp = $this->latestTimestamp($snapshot->generated_at, $snapshot->updated_at, $snapshot->created_at);
|
|
|
|
if (! $reportTimestamp instanceof CarbonInterface) {
|
|
return false;
|
|
}
|
|
|
|
if (! $snapshotTimestamp instanceof CarbonInterface) {
|
|
return true;
|
|
}
|
|
|
|
if ($reportTimestamp->greaterThan($snapshotTimestamp)) {
|
|
return true;
|
|
}
|
|
|
|
if ($reportTimestamp->lessThan($snapshotTimestamp)) {
|
|
return false;
|
|
}
|
|
|
|
return ! is_numeric($itemSourceRecordId)
|
|
|| (int) $report->getKey() > (int) $itemSourceRecordId;
|
|
}
|
|
|
|
private function reportCanSupersedeOperation(StoredReport $report, ?OperationRun $operationRun): bool
|
|
{
|
|
if (! $operationRun instanceof OperationRun) {
|
|
return true;
|
|
}
|
|
|
|
$reportTimestamp = $this->latestTimestamp($report->generated_at, $report->updated_at, $report->created_at);
|
|
$operationTimestamp = $this->latestTimestamp($operationRun->completed_at, $operationRun->started_at, $operationRun->updated_at, $operationRun->created_at);
|
|
|
|
return $reportTimestamp instanceof CarbonInterface
|
|
&& $operationTimestamp instanceof CarbonInterface
|
|
&& $reportTimestamp->greaterThan($operationTimestamp);
|
|
}
|
|
|
|
private function latestTimestamp(?CarbonInterface ...$timestamps): ?CarbonInterface
|
|
{
|
|
return collect($timestamps)
|
|
->filter(static fn (?CarbonInterface $timestamp): bool => $timestamp instanceof CarbonInterface)
|
|
->sortByDesc(static fn (CarbonInterface $timestamp): int => $timestamp->getTimestamp())
|
|
->first();
|
|
}
|
|
|
|
private function sameReviewScope(EnvironmentReview $review, EvidenceSnapshot|ReviewPack $artifact): bool
|
|
{
|
|
return (int) $artifact->workspace_id === (int) $review->workspace_id
|
|
&& (int) $artifact->managed_environment_id === (int) $review->managed_environment_id;
|
|
}
|
|
|
|
private function sameOperationScope(EnvironmentReview $review, OperationRun $operationRun): bool
|
|
{
|
|
return (int) $operationRun->workspace_id === (int) $review->workspace_id
|
|
&& (int) $operationRun->managed_environment_id === (int) $review->managed_environment_id;
|
|
}
|
|
|
|
private function reviewPackMatchesCurrentOutput(EnvironmentReview $review, ReviewPack $reviewPack): bool
|
|
{
|
|
if (! is_string($reviewPack->fingerprint) || $reviewPack->fingerprint === '') {
|
|
return false;
|
|
}
|
|
|
|
$options = is_array($reviewPack->options) ? $reviewPack->options : [];
|
|
$expectedFingerprint = $this->reviewPacks->computeFingerprintForReview($review, $options);
|
|
|
|
return hash_equals($expectedFingerprint, (string) $reviewPack->fingerprint);
|
|
}
|
|
|
|
private function operationMismatchReason(
|
|
ReviewPublicationResolutionStepKey $stepKey,
|
|
EnvironmentReview $review,
|
|
ReviewPublicationResolutionStep $step,
|
|
OperationRun $operationRun,
|
|
): ?string {
|
|
$expectedTypes = $this->expectedOperationTypes($stepKey);
|
|
|
|
if ($expectedTypes !== [] && ! in_array((string) $operationRun->type, $expectedTypes, true)) {
|
|
return 'proof.operation_type_mismatch';
|
|
}
|
|
|
|
$context = is_array($operationRun->context) ? $operationRun->context : [];
|
|
$contextReviewIds = [];
|
|
|
|
foreach (['environment_review_id', 'review_id'] as $reviewContextKey) {
|
|
$contextReviewId = data_get($context, $reviewContextKey);
|
|
|
|
if ($contextReviewId === null) {
|
|
continue;
|
|
}
|
|
|
|
if (! is_numeric($contextReviewId)) {
|
|
return 'proof.operation_context_mismatch';
|
|
}
|
|
|
|
$contextReviewIds[$reviewContextKey] = (int) $contextReviewId;
|
|
}
|
|
|
|
$contextCaseId = data_get($context, 'review_publication_resolution_case_id');
|
|
|
|
if ($contextCaseId !== null && ! is_numeric($contextCaseId)) {
|
|
return 'proof.operation_context_mismatch';
|
|
}
|
|
|
|
if (count(array_unique(array_values($contextReviewIds))) > 1) {
|
|
return 'proof.operation_context_mismatch';
|
|
}
|
|
|
|
foreach ($contextReviewIds as $contextReviewId) {
|
|
if ($contextReviewId !== (int) $review->getKey()) {
|
|
return 'proof.operation_context_mismatch';
|
|
}
|
|
}
|
|
|
|
if ($contextCaseId !== null && (int) $contextCaseId !== (int) $step->case_id) {
|
|
return 'proof.operation_context_mismatch';
|
|
}
|
|
|
|
if ($contextReviewIds !== [] || $contextCaseId !== null) {
|
|
return null;
|
|
}
|
|
|
|
if ($this->operationSourceArtifactMatchesStep($stepKey, $review, $step, $operationRun)) {
|
|
return null;
|
|
}
|
|
|
|
return 'proof.operation_context_missing';
|
|
}
|
|
|
|
private function operationHasCurrentResolutionContext(
|
|
ReviewPublicationResolutionStepKey $stepKey,
|
|
EnvironmentReview $review,
|
|
ReviewPublicationResolutionStep $step,
|
|
OperationRun $operationRun,
|
|
): bool {
|
|
$context = is_array($operationRun->context) ? $operationRun->context : [];
|
|
$contextReviewIds = [];
|
|
|
|
foreach (['environment_review_id', 'review_id'] as $reviewContextKey) {
|
|
$contextReviewId = data_get($context, $reviewContextKey);
|
|
|
|
if ($contextReviewId === null || ! is_numeric($contextReviewId)) {
|
|
continue;
|
|
}
|
|
|
|
$contextReviewIds[] = (int) $contextReviewId;
|
|
}
|
|
|
|
$hasCurrentReviewContext = $contextReviewIds !== []
|
|
&& count(array_unique($contextReviewIds)) === 1
|
|
&& $contextReviewIds[0] === (int) $review->getKey();
|
|
$contextCaseId = data_get($context, 'review_publication_resolution_case_id');
|
|
$hasCurrentCaseContext = is_numeric($contextCaseId)
|
|
&& (int) $contextCaseId === (int) $step->case_id;
|
|
|
|
if (! $hasCurrentReviewContext && ! $hasCurrentCaseContext) {
|
|
return false;
|
|
}
|
|
|
|
$expectedTypes = $this->expectedOperationTypes($stepKey);
|
|
|
|
return $expectedTypes === [] || in_array((string) $operationRun->type, $expectedTypes, true);
|
|
}
|
|
|
|
private function operationSourceArtifactMatchesStep(
|
|
ReviewPublicationResolutionStepKey $stepKey,
|
|
EnvironmentReview $review,
|
|
ReviewPublicationResolutionStep $step,
|
|
OperationRun $operationRun,
|
|
): bool {
|
|
if (! is_string($step->proof_type) || ! is_numeric($step->proof_id)) {
|
|
return false;
|
|
}
|
|
|
|
return match ($stepKey) {
|
|
ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot => $step->proof_type === 'evidence_snapshot'
|
|
&& EvidenceSnapshot::query()
|
|
->whereKey((int) $step->proof_id)
|
|
->where('workspace_id', (int) $review->workspace_id)
|
|
->where('managed_environment_id', (int) $review->managed_environment_id)
|
|
->where('operation_run_id', (int) $operationRun->getKey())
|
|
->exists(),
|
|
ReviewPublicationResolutionStepKey::RefreshReviewComposition => $step->proof_type === 'environment_review'
|
|
&& (int) $step->proof_id === (int) $review->getKey()
|
|
&& EnvironmentReview::query()
|
|
->whereKey((int) $review->getKey())
|
|
->where('workspace_id', (int) $review->workspace_id)
|
|
->where('managed_environment_id', (int) $review->managed_environment_id)
|
|
->where('operation_run_id', (int) $operationRun->getKey())
|
|
->exists(),
|
|
ReviewPublicationResolutionStepKey::GenerateReviewPack => $step->proof_type === 'review_pack'
|
|
&& ReviewPack::query()
|
|
->whereKey((int) $step->proof_id)
|
|
->where('workspace_id', (int) $review->workspace_id)
|
|
->where('managed_environment_id', (int) $review->managed_environment_id)
|
|
->where('environment_review_id', (int) $review->getKey())
|
|
->where('operation_run_id', (int) $operationRun->getKey())
|
|
->exists(),
|
|
default => false,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
private function expectedOperationTypes(ReviewPublicationResolutionStepKey $stepKey): array
|
|
{
|
|
return match ($stepKey) {
|
|
ReviewPublicationResolutionStepKey::CompleteRequiredReports => [
|
|
self::PROVIDER_CONNECTION_CHECK_OPERATION_TYPE,
|
|
OperationRunType::EntraAdminRolesScan->value,
|
|
],
|
|
ReviewPublicationResolutionStepKey::CollectEvidenceSnapshot => [
|
|
OperationRunType::EvidenceSnapshotGenerate->value,
|
|
],
|
|
ReviewPublicationResolutionStepKey::RefreshReviewComposition => [
|
|
OperationRunType::EnvironmentReviewCompose->value,
|
|
],
|
|
ReviewPublicationResolutionStepKey::GenerateReviewPack => [
|
|
OperationRunType::ReviewPackGenerate->value,
|
|
],
|
|
ReviewPublicationResolutionStepKey::ValidateReviewReadiness,
|
|
ReviewPublicationResolutionStepKey::ReturnToPublication => [],
|
|
};
|
|
}
|
|
|
|
private function operationMismatch(
|
|
EnvironmentReview $review,
|
|
ReviewPublicationResolutionStepKey $stepKey,
|
|
OperationRun $operationRun,
|
|
string $reasonCode,
|
|
): ResolutionProofEvaluation {
|
|
return new ResolutionProofEvaluation(
|
|
actionKey: $stepKey,
|
|
subjectType: EnvironmentReview::class,
|
|
subjectId: (int) $review->getKey(),
|
|
status: ResolutionProofStatus::Unknown,
|
|
currentness: ResolutionProofCurrentness::Unknown,
|
|
usability: ResolutionProofUsability::NotUsable,
|
|
visibility: ResolutionProofVisibility::OperatorVisible,
|
|
reasonCode: $reasonCode,
|
|
evaluatedAt: now(),
|
|
safeSummary: [
|
|
'label' => 'Linked operation does not match the current step',
|
|
'operation_type' => (string) $operationRun->type,
|
|
],
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, ResolutionProofStatus> $statusMap
|
|
*/
|
|
private function artifactStatus(string $status, array $statusMap): ResolutionProofStatus
|
|
{
|
|
return $statusMap[$status] ?? ResolutionProofStatus::Unknown;
|
|
}
|
|
|
|
private function supersededOperationRunId(?ReviewPublicationResolutionStep $existingStep): ?int
|
|
{
|
|
if (! $existingStep instanceof ReviewPublicationResolutionStep || ! is_numeric($existingStep->operation_run_id)) {
|
|
return null;
|
|
}
|
|
|
|
if (! in_array($existingStep->statusEnum(), [
|
|
ReviewPublicationResolutionStepStatus::Running,
|
|
ReviewPublicationResolutionStepStatus::Failed,
|
|
], true)) {
|
|
return null;
|
|
}
|
|
|
|
return (int) $existingStep->operation_run_id;
|
|
}
|
|
|
|
private function shouldRejectDirectOperationBeforeArtifactProof(?ReviewPublicationResolutionStep $existingStep, EnvironmentReview $review): bool
|
|
{
|
|
if (! $existingStep instanceof ReviewPublicationResolutionStep) {
|
|
return true;
|
|
}
|
|
|
|
$operationRun = $existingStep->operationRun;
|
|
|
|
if (! $operationRun instanceof OperationRun || ! $this->sameOperationScope($review, $operationRun)) {
|
|
return true;
|
|
}
|
|
|
|
$mismatchReason = $this->operationMismatchReason(
|
|
ReviewPublicationResolutionStepKey::CompleteRequiredReports,
|
|
$review,
|
|
$existingStep,
|
|
$operationRun,
|
|
);
|
|
|
|
if ($mismatchReason === null) {
|
|
return false;
|
|
}
|
|
|
|
if ($mismatchReason !== 'proof.operation_context_missing') {
|
|
return true;
|
|
}
|
|
|
|
return (string) $operationRun->status !== OperationRunStatus::Completed->value;
|
|
}
|
|
|
|
private function validSupersededOperationRunId(?ReviewPublicationResolutionStep $existingStep, EnvironmentReview $review): ?int
|
|
{
|
|
$operationRunId = $this->supersededOperationRunId($existingStep);
|
|
|
|
if ($operationRunId === null || ! $existingStep instanceof ReviewPublicationResolutionStep) {
|
|
return null;
|
|
}
|
|
|
|
$operationRun = $existingStep->operationRun;
|
|
|
|
if (! $operationRun instanceof OperationRun
|
|
|| ! $this->sameOperationScope($review, $operationRun)
|
|
|| $this->operationMismatchReason(ReviewPublicationResolutionStepKey::CompleteRequiredReports, $review, $existingStep, $operationRun) !== null) {
|
|
return null;
|
|
}
|
|
|
|
return $operationRunId;
|
|
}
|
|
|
|
private function previouslySupersededOperationRunId(?ReviewPublicationResolutionStep $existingStep): ?int
|
|
{
|
|
if (! $existingStep instanceof ReviewPublicationResolutionStep) {
|
|
return null;
|
|
}
|
|
|
|
$reasonCode = (string) data_get($existingStep->metadata, 'proof_reason_code', '');
|
|
$operationRunId = data_get($existingStep->metadata, 'proof_summary.superseded_operation_run_id');
|
|
|
|
if (! str_contains($reasonCode, 'supersede') || ! is_numeric($operationRunId)) {
|
|
return null;
|
|
}
|
|
|
|
return (int) $operationRunId;
|
|
}
|
|
|
|
private function validPreviouslySupersededOperationRunId(?ReviewPublicationResolutionStep $existingStep, EnvironmentReview $review): ?int
|
|
{
|
|
$operationRunId = $this->previouslySupersededOperationRunId($existingStep);
|
|
|
|
if ($operationRunId === null) {
|
|
return null;
|
|
}
|
|
|
|
$operationRun = OperationRun::query()->find($operationRunId);
|
|
|
|
if (! $operationRun instanceof OperationRun
|
|
|| ! $this->sameOperationScope($review, $operationRun)
|
|
|| ! ($existingStep instanceof ReviewPublicationResolutionStep)
|
|
|| $this->operationMismatchReason(ReviewPublicationResolutionStepKey::CompleteRequiredReports, $review, $existingStep, $operationRun) !== null) {
|
|
return null;
|
|
}
|
|
|
|
$expectedTypes = $this->expectedOperationTypes(ReviewPublicationResolutionStepKey::CompleteRequiredReports);
|
|
|
|
if ($expectedTypes !== [] && ! in_array((string) $operationRun->type, $expectedTypes, true)) {
|
|
return null;
|
|
}
|
|
|
|
return $operationRunId;
|
|
}
|
|
}
|