mirror of
https://github.com/modrinth/code.git
synced 2026-07-31 21:26:40 +00:00
feat: global trace database frontend + improvements to tech review status sync (#6671)
* wip * ui edits * better logic for syncing tech review state in a single centralized place * never automatically finish a tech review report * fix css * fix clippy * prepr
This commit is contained in:
@@ -55,7 +55,18 @@
|
||||
{{ formatTraceCount(trace.local_trace_count) }}
|
||||
</p>
|
||||
</div>
|
||||
<Badge :type="trace.verdict" />
|
||||
<div class="flex shrink-0 flex-wrap items-center gap-2">
|
||||
<Badge :type="trace.verdict" />
|
||||
<ButtonStyled color="red">
|
||||
<button
|
||||
:disabled="removingTraceKeys.has(trace.detail_key)"
|
||||
@click="removeGlobalTrace(trace)"
|
||||
>
|
||||
<TrashIcon aria-hidden="true" />
|
||||
Remove
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="getPreviewLocalTraces(trace).length > 0" class="flex flex-col gap-2">
|
||||
@@ -97,12 +108,13 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { HashIcon, ListIcon, SearchIcon } from '@modrinth/assets'
|
||||
import { HashIcon, ListIcon, SearchIcon, TrashIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Badge,
|
||||
ButtonStyled,
|
||||
EmptyState,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
Pagination,
|
||||
StyledInput,
|
||||
} from '@modrinth/ui'
|
||||
@@ -110,6 +122,7 @@ import {
|
||||
import GlobalDetailLocalTraceCard from '~/components/ui/moderation/GlobalDetailLocalTraceCard.vue'
|
||||
|
||||
const client = injectModrinthClient()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const query = ref('')
|
||||
const activeQuery = ref<string | null>(null)
|
||||
const isLoading = ref(false)
|
||||
@@ -119,6 +132,7 @@ const itemsPerPage = 20
|
||||
const localTracePreviewLimit = 10
|
||||
const total = ref(0)
|
||||
const traces = ref<Labrinth.TechReview.Internal.GlobalIssueDetail[]>([])
|
||||
const removingTraceKeys = reactive<Set<string>>(new Set())
|
||||
|
||||
const pageCount = computed(() => Math.max(Math.ceil(total.value / itemsPerPage), 1))
|
||||
const pageStart = computed(() =>
|
||||
@@ -176,5 +190,37 @@ async function switchPage(page: number) {
|
||||
await loadTraces()
|
||||
}
|
||||
|
||||
async function removeGlobalTrace(trace: Labrinth.TechReview.Internal.GlobalIssueDetail) {
|
||||
if (removingTraceKeys.has(trace.detail_key)) return
|
||||
|
||||
removingTraceKeys.add(trace.detail_key)
|
||||
try {
|
||||
await client.labrinth.tech_review_internal.updateGlobalIssueDetails([
|
||||
{ detail_key: trace.detail_key, verdict: 'pending' },
|
||||
])
|
||||
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: 'Global trace removed',
|
||||
text: 'The global verdict for this trace key has been removed.',
|
||||
})
|
||||
|
||||
if (traces.value.length === 1 && currentPage.value > 1) {
|
||||
currentPage.value--
|
||||
}
|
||||
|
||||
await loadTraces()
|
||||
} catch (error) {
|
||||
console.error('Failed to remove global trace', error)
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Failed to remove global trace',
|
||||
text: 'An error occurred while removing the global trace verdict.',
|
||||
})
|
||||
} finally {
|
||||
removingTraceKeys.delete(trace.detail_key)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadTraces)
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import {
|
||||
BanIcon,
|
||||
BugIcon,
|
||||
CheckCheckIcon,
|
||||
CheckCircleIcon,
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
@@ -14,6 +16,7 @@ import {
|
||||
EyeOffIcon,
|
||||
LoaderCircleIcon,
|
||||
ScaleIcon,
|
||||
ShieldAlertIcon,
|
||||
ShieldCheckIcon,
|
||||
SpinnerIcon,
|
||||
TimerIcon,
|
||||
@@ -207,15 +210,31 @@ async function updateIssueDetails(
|
||||
})
|
||||
}
|
||||
|
||||
async function updateGlobalIssueDetail(
|
||||
detailKey: string,
|
||||
verdict: Labrinth.TechReview.Internal.DelphiReportIssueStatus,
|
||||
) {
|
||||
await client.labrinth.tech_review_internal.updateGlobalIssueDetails([
|
||||
{ detail_key: detailKey, verdict },
|
||||
])
|
||||
}
|
||||
|
||||
const severityOrder = { severe: 3, high: 2, medium: 1, low: 0 } as Record<string, number>
|
||||
|
||||
type DetailDecision = 'safe' | 'malware'
|
||||
type DetailDecision = 'safe' | 'malware' | 'pending'
|
||||
type DetailDecisionScope = 'local' | 'global'
|
||||
|
||||
const detailDecisions = reactive<Map<string, DetailDecision>>(new Map())
|
||||
const detailDecisionScopes = reactive<Map<string, DetailDecisionScope>>(new Map())
|
||||
const updatingDetails = reactive<Set<string>>(new Set())
|
||||
const updatingGlobalDetailKeys = reactive<Set<string>>(new Set())
|
||||
|
||||
function verdictToDecision(verdict: 'safe' | 'unsafe'): DetailDecision {
|
||||
return verdict === 'safe' ? 'safe' : 'malware'
|
||||
function verdictToDecision(
|
||||
verdict: Labrinth.TechReview.Internal.DelphiReportIssueStatus,
|
||||
): DetailDecision {
|
||||
if (verdict === 'safe') return 'safe'
|
||||
if (verdict === 'unsafe') return 'malware'
|
||||
return 'pending'
|
||||
}
|
||||
|
||||
function getAllDetails(): Labrinth.TechReview.Internal.ReportIssueDetail[] {
|
||||
@@ -225,6 +244,7 @@ function getAllDetails(): Labrinth.TechReview.Internal.ReportIssueDetail[] {
|
||||
function applyDecisionToRelatedDetails(
|
||||
detailIds: string[],
|
||||
decision: DetailDecision,
|
||||
scope: DetailDecisionScope,
|
||||
): { otherMatchedCount: number } {
|
||||
const allDetails = getAllDetails()
|
||||
const selectedDetailIds = new Set(detailIds)
|
||||
@@ -242,12 +262,14 @@ function applyDecisionToRelatedDetails(
|
||||
|
||||
if (matchingDetails.length === 0) {
|
||||
detailDecisions.set(detailId, decision)
|
||||
detailDecisionScopes.set(detailId, scope)
|
||||
updatedDetailIds.add(detailId)
|
||||
continue
|
||||
}
|
||||
|
||||
for (const matchingDetail of matchingDetails) {
|
||||
detailDecisions.set(matchingDetail.id, decision)
|
||||
detailDecisionScopes.set(matchingDetail.id, scope)
|
||||
updatedDetailIds.add(matchingDetail.id)
|
||||
}
|
||||
}
|
||||
@@ -258,6 +280,98 @@ function applyDecisionToRelatedDetails(
|
||||
}
|
||||
}
|
||||
|
||||
function statusMatchesDecision(
|
||||
status: Labrinth.TechReview.Internal.DelphiReportIssueStatus | null,
|
||||
decision: DetailDecision,
|
||||
): boolean {
|
||||
if (status === 'safe') return decision === 'safe'
|
||||
if (status === 'unsafe') return decision === 'malware'
|
||||
return false
|
||||
}
|
||||
|
||||
function isDetailActionSelected(
|
||||
detail: Labrinth.TechReview.Internal.ReportIssueDetail,
|
||||
decision: DetailDecision,
|
||||
scope: DetailDecisionScope,
|
||||
): boolean {
|
||||
const localDecision = detailDecisions.get(detail.id)
|
||||
const localScope = detailDecisionScopes.get(detail.id)
|
||||
if (localDecision && localScope) {
|
||||
if (localDecision === 'pending') {
|
||||
if (localScope === 'local') {
|
||||
if (scope === 'local') return false
|
||||
return statusMatchesDecision(detail.global_status, decision)
|
||||
}
|
||||
|
||||
if (scope === 'global') return false
|
||||
return statusMatchesDecision(detail.local_status, decision)
|
||||
}
|
||||
|
||||
return localDecision === decision && localScope === scope
|
||||
}
|
||||
|
||||
if (scope === 'global') {
|
||||
return statusMatchesDecision(detail.global_status, decision)
|
||||
}
|
||||
|
||||
if (detail.global_status) {
|
||||
return false
|
||||
}
|
||||
|
||||
return statusMatchesDecision(detail.local_status, decision)
|
||||
}
|
||||
|
||||
function decisionToVerdict(
|
||||
decision: Exclude<DetailDecision, 'pending'>,
|
||||
): Labrinth.TechReview.Internal.DelphiReportIssueStatus {
|
||||
return decision === 'safe' ? 'safe' : 'unsafe'
|
||||
}
|
||||
|
||||
function getToggledDetailVerdict(
|
||||
detail: Labrinth.TechReview.Internal.ReportIssueDetail,
|
||||
decision: Exclude<DetailDecision, 'pending'>,
|
||||
scope: DetailDecisionScope,
|
||||
): Labrinth.TechReview.Internal.DelphiReportIssueStatus {
|
||||
return isDetailActionSelected(detail, decision, scope) ? 'pending' : decisionToVerdict(decision)
|
||||
}
|
||||
|
||||
function getDetailActionTooltip(
|
||||
detail: Labrinth.TechReview.Internal.ReportIssueDetail,
|
||||
decision: Exclude<DetailDecision, 'pending'>,
|
||||
scope: DetailDecisionScope,
|
||||
): string {
|
||||
const action = decision === 'safe' ? 'pass' : 'fail'
|
||||
const scopeLabel = scope === 'global' ? 'Global' : 'Local'
|
||||
|
||||
if (scope === 'global' && !canUpdateGlobalDetail(detail)) {
|
||||
return 'Global verdict unavailable for generated trace keys'
|
||||
}
|
||||
|
||||
if (isDetailActionSelected(detail, decision, scope)) {
|
||||
return `Unset ${scopeLabel.toLowerCase()} ${action}`
|
||||
}
|
||||
|
||||
return `${scopeLabel} ${action}`
|
||||
}
|
||||
|
||||
function updateLocalDetailAction(
|
||||
detail: Labrinth.TechReview.Internal.ReportIssueDetail,
|
||||
decision: Exclude<DetailDecision, 'pending'>,
|
||||
) {
|
||||
return updateDetailStatus(detail.id, getToggledDetailVerdict(detail, decision, 'local'))
|
||||
}
|
||||
|
||||
function updateGlobalDetailAction(
|
||||
detail: Labrinth.TechReview.Internal.ReportIssueDetail,
|
||||
decision: Exclude<DetailDecision, 'pending'>,
|
||||
) {
|
||||
return updateGlobalDetailStatus(detail, getToggledDetailVerdict(detail, decision, 'global'))
|
||||
}
|
||||
|
||||
function canUpdateGlobalDetail(detail: Labrinth.TechReview.Internal.ReportIssueDetail): boolean {
|
||||
return detail.key.length > 0 && !detail.key.startsWith('<no-key-')
|
||||
}
|
||||
|
||||
function getFileHighestSeverity(
|
||||
file: FlattenedFileReport,
|
||||
): Labrinth.TechReview.Internal.DelphiSeverity {
|
||||
@@ -495,6 +609,18 @@ const remainingUnmarkedCount = computed(() => {
|
||||
return getFileDetailCount(selectedFile.value) - getFileMarkedCount(selectedFile.value)
|
||||
})
|
||||
|
||||
function getJarFlags(jarGroup: JarGroup): ClassGroup['flags'] {
|
||||
return jarGroup.classes.flatMap((classItem) => classItem.flags)
|
||||
}
|
||||
|
||||
function getJarMarkedCount(jarGroup: JarGroup): number {
|
||||
return getMarkedFlagsCount(getJarFlags(jarGroup))
|
||||
}
|
||||
|
||||
function getJarRemainingUnmarkedCount(jarGroup: JarGroup): number {
|
||||
return getJarFlags(jarGroup).length - getJarMarkedCount(jarGroup)
|
||||
}
|
||||
|
||||
const isBatchUpdating = ref(false)
|
||||
|
||||
async function batchMarkRemaining(verdict: 'safe' | 'unsafe') {
|
||||
@@ -518,7 +644,7 @@ async function batchMarkRemaining(verdict: 'safe' | 'unsafe') {
|
||||
try {
|
||||
await updateIssueDetails(detailIds.map((detailId) => ({ detail_id: detailId, verdict })))
|
||||
|
||||
applyDecisionToRelatedDetails(detailIds, verdictToDecision(verdict))
|
||||
applyDecisionToRelatedDetails(detailIds, verdictToDecision(verdict), 'local')
|
||||
|
||||
addNotification({
|
||||
type: 'success',
|
||||
@@ -548,7 +674,54 @@ async function batchMarkRemaining(verdict: 'safe' | 'unsafe') {
|
||||
}
|
||||
}
|
||||
|
||||
async function updateDetailStatus(detailId: string, verdict: 'safe' | 'unsafe') {
|
||||
async function batchMarkRemainingInJar(jarGroup: JarGroup, verdict: 'safe' | 'unsafe') {
|
||||
if (isBatchUpdating.value) return
|
||||
|
||||
const detailIds = getJarFlags(jarGroup)
|
||||
.filter((flag) => getDetailDecision(flag.detail.id, flag.detail.status) === 'pending')
|
||||
.map((flag) => flag.detail.id)
|
||||
|
||||
if (detailIds.length === 0) return
|
||||
|
||||
isBatchUpdating.value = true
|
||||
try {
|
||||
await updateIssueDetails(detailIds.map((detailId) => ({ detail_id: detailId, verdict })))
|
||||
|
||||
applyDecisionToRelatedDetails(detailIds, verdictToDecision(verdict), 'local')
|
||||
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: `Marked ${detailIds.length} traces as ${verdict}`,
|
||||
text: `All remaining traces in this JAR have been marked as ${
|
||||
verdict === 'safe' ? 'false positives' : 'malicious'
|
||||
}.`,
|
||||
})
|
||||
|
||||
if (selectedFile.value) {
|
||||
const markedCount = getFileMarkedCount(selectedFile.value)
|
||||
const totalCount = getFileDetailCount(selectedFile.value)
|
||||
if (markedCount === totalCount) {
|
||||
backToFileList()
|
||||
}
|
||||
}
|
||||
|
||||
emit('refetch')
|
||||
} catch (error) {
|
||||
console.error('Failed to batch update JAR traces:', error)
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Batch update failed',
|
||||
text: 'An error occurred while updating JAR traces.',
|
||||
})
|
||||
} finally {
|
||||
isBatchUpdating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function updateDetailStatus(
|
||||
detailId: string,
|
||||
verdict: Labrinth.TechReview.Internal.DelphiReportIssueStatus,
|
||||
) {
|
||||
let priorDecision: 'safe' | 'malware' | 'pending' = 'pending'
|
||||
outer: for (const report of props.item.reports) {
|
||||
for (const issue of report.issues) {
|
||||
@@ -568,10 +741,11 @@ async function updateDetailStatus(detailId: string, verdict: 'safe' | 'unsafe')
|
||||
const { otherMatchedCount } = applyDecisionToRelatedDetails(
|
||||
[detailId],
|
||||
verdictToDecision(verdict),
|
||||
'local',
|
||||
)
|
||||
|
||||
// Only collapse if the prior state was 'pending' (new decision, not updating existing)
|
||||
if (priorDecision === 'pending') {
|
||||
if (verdict !== 'pending' && priorDecision === 'pending') {
|
||||
for (const classGroup of groupedByClass.value) {
|
||||
const hasThisDetail = classGroup.flags.some((f) => f.detail.id === detailId)
|
||||
if (hasThisDetail && getMarkedFlagsCount(classGroup.flags) === classGroup.flags.length) {
|
||||
@@ -582,7 +756,7 @@ async function updateDetailStatus(detailId: string, verdict: 'safe' | 'unsafe')
|
||||
}
|
||||
|
||||
// Jump back to Files tab when all flags in the current file are marked
|
||||
if (selectedFile.value) {
|
||||
if (verdict !== 'pending' && selectedFile.value) {
|
||||
const markedCount = getFileMarkedCount(selectedFile.value)
|
||||
const totalCount = getFileDetailCount(selectedFile.value)
|
||||
if (markedCount === totalCount) {
|
||||
@@ -595,7 +769,13 @@ async function updateDetailStatus(detailId: string, verdict: 'safe' | 'unsafe')
|
||||
? ` (${otherMatchedCount} other trace${otherMatchedCount === 1 ? '' : 's'} also marked)`
|
||||
: ''
|
||||
|
||||
if (verdict === 'safe') {
|
||||
if (verdict === 'pending') {
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: 'Local trace verdict unset',
|
||||
text: `The project-local verdict has been removed.${otherText}`,
|
||||
})
|
||||
} else if (verdict === 'safe') {
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: 'Issue marked as pass',
|
||||
@@ -622,6 +802,82 @@ async function updateDetailStatus(detailId: string, verdict: 'safe' | 'unsafe')
|
||||
}
|
||||
}
|
||||
|
||||
async function updateGlobalDetailStatus(
|
||||
detail: Labrinth.TechReview.Internal.ReportIssueDetail,
|
||||
verdict: Labrinth.TechReview.Internal.DelphiReportIssueStatus,
|
||||
) {
|
||||
if (!canUpdateGlobalDetail(detail)) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Global update unavailable',
|
||||
text: 'Generated trace keys cannot be marked globally.',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
updatingGlobalDetailKeys.add(detail.key)
|
||||
|
||||
try {
|
||||
await updateGlobalIssueDetail(detail.key, verdict)
|
||||
|
||||
const { otherMatchedCount } = applyDecisionToRelatedDetails(
|
||||
[detail.id],
|
||||
verdictToDecision(verdict),
|
||||
'global',
|
||||
)
|
||||
|
||||
if (verdict !== 'pending') {
|
||||
for (const classGroup of groupedByClass.value) {
|
||||
if (getMarkedFlagsCount(classGroup.flags) === classGroup.flags.length) {
|
||||
expandedClasses.delete(classGroup.key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (verdict !== 'pending' && selectedFile.value) {
|
||||
const markedCount = getFileMarkedCount(selectedFile.value)
|
||||
const totalCount = getFileDetailCount(selectedFile.value)
|
||||
if (markedCount === totalCount) {
|
||||
backToFileList()
|
||||
}
|
||||
}
|
||||
|
||||
const otherText =
|
||||
otherMatchedCount > 0
|
||||
? ` (${otherMatchedCount} other trace${otherMatchedCount === 1 ? '' : 's'} also marked in this project)`
|
||||
: ''
|
||||
|
||||
if (verdict === 'pending') {
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: 'Global trace verdict unset',
|
||||
text: `The global verdict for this trace key has been removed.${otherText}`,
|
||||
})
|
||||
} else {
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title:
|
||||
verdict === 'safe' ? 'Trace globally marked as pass' : 'Trace globally marked as fail',
|
||||
text:
|
||||
verdict === 'safe'
|
||||
? `This trace key has been marked as a global false positive.${otherText}`
|
||||
: `This trace key has been globally flagged as malicious.${otherText}`,
|
||||
})
|
||||
}
|
||||
|
||||
emit('refetch')
|
||||
} catch (error) {
|
||||
console.error('Failed to update global detail status:', error)
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Failed to update global trace',
|
||||
text: 'An error occurred while updating the global trace status.',
|
||||
})
|
||||
} finally {
|
||||
updatingGlobalDetailKeys.delete(detail.key)
|
||||
}
|
||||
}
|
||||
|
||||
const expandedClasses = reactive<Set<string>>(new Set())
|
||||
const autoExpandedFileIds = reactive<Set<string>>(new Set())
|
||||
const showCopyFeedback = reactive<Map<string, boolean>>(new Map())
|
||||
@@ -1352,26 +1608,49 @@ function copyId() {
|
||||
v-if="jarGroup.segments.length > 0"
|
||||
class="border-b border-solid border-surface-1 px-4 py-3"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-1">
|
||||
<template
|
||||
v-for="(segment, index) in jarGroup.segments"
|
||||
:key="`${jarGroup.key}-${index}`"
|
||||
>
|
||||
<span
|
||||
class="font-mono text-sm"
|
||||
:class="
|
||||
index === jarGroup.segments.length - 1
|
||||
? 'font-semibold text-contrast'
|
||||
: 'text-secondary'
|
||||
"
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex flex-wrap items-center gap-1">
|
||||
<template
|
||||
v-for="(segment, index) in jarGroup.segments"
|
||||
:key="`${jarGroup.key}-${index}`"
|
||||
>
|
||||
{{ segment }}
|
||||
</span>
|
||||
<ChevronRightIcon
|
||||
v-if="index < jarGroup.segments.length - 1"
|
||||
class="size-4 text-secondary"
|
||||
/>
|
||||
</template>
|
||||
<span
|
||||
class="font-mono text-sm"
|
||||
:class="
|
||||
index === jarGroup.segments.length - 1
|
||||
? 'font-semibold text-contrast'
|
||||
: 'text-secondary'
|
||||
"
|
||||
>
|
||||
{{ segment }}
|
||||
</span>
|
||||
<ChevronRightIcon
|
||||
v-if="index < jarGroup.segments.length - 1"
|
||||
class="size-4 text-secondary"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="getJarRemainingUnmarkedCount(jarGroup) > 0" class="flex gap-2">
|
||||
<ButtonStyled color="brand" size="small">
|
||||
<button
|
||||
:disabled="isBatchUpdating"
|
||||
@click="batchMarkRemainingInJar(jarGroup, 'safe')"
|
||||
>
|
||||
<CheckCircleIcon class="size-4" />
|
||||
Remaining safe ({{ getJarRemainingUnmarkedCount(jarGroup) }})
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red" size="small">
|
||||
<button
|
||||
:disabled="isBatchUpdating"
|
||||
@click="batchMarkRemainingInJar(jarGroup, 'unsafe')"
|
||||
>
|
||||
<TriangleAlertIcon class="size-4" />
|
||||
Remaining malware ({{ getJarRemainingUnmarkedCount(jarGroup) }})
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1470,38 +1749,94 @@ function copyId() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex w-40 items-center justify-center gap-2">
|
||||
<ButtonStyled
|
||||
color="brand"
|
||||
:type="
|
||||
getDetailDecision(flag.detail.id, flag.detail.status) === 'safe'
|
||||
? undefined
|
||||
: 'outlined'
|
||||
"
|
||||
<div class="detail-verdict-action-groups">
|
||||
<div
|
||||
class="detail-verdict-buttons"
|
||||
role="group"
|
||||
aria-label="Trace verdict actions"
|
||||
>
|
||||
<button
|
||||
:disabled="updatingDetails.has(flag.detail.id)"
|
||||
@click="updateDetailStatus(flag.detail.id, 'safe')"
|
||||
v-tooltip="getDetailActionTooltip(flag.detail, 'safe', 'global')"
|
||||
class="detail-verdict-button detail-verdict-button--safe"
|
||||
:class="{
|
||||
'detail-verdict-button--selected': isDetailActionSelected(
|
||||
flag.detail,
|
||||
'safe',
|
||||
'global',
|
||||
),
|
||||
}"
|
||||
aria-label="Global pass"
|
||||
:disabled="
|
||||
!canUpdateGlobalDetail(flag.detail) ||
|
||||
updatingGlobalDetailKeys.has(flag.detail.key) ||
|
||||
updatingDetails.has(flag.detail.id)
|
||||
"
|
||||
@click="updateGlobalDetailAction(flag.detail, 'safe')"
|
||||
>
|
||||
Pass
|
||||
<CheckCheckIcon aria-hidden="true" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
<ButtonStyled
|
||||
color="red"
|
||||
:type="
|
||||
getDetailDecision(flag.detail.id, flag.detail.status) === 'malware'
|
||||
? undefined
|
||||
: 'outlined'
|
||||
"
|
||||
>
|
||||
<button
|
||||
:disabled="updatingDetails.has(flag.detail.id)"
|
||||
@click="updateDetailStatus(flag.detail.id, 'unsafe')"
|
||||
v-tooltip="getDetailActionTooltip(flag.detail, 'safe', 'local')"
|
||||
class="detail-verdict-button detail-verdict-button--safe"
|
||||
:class="{
|
||||
'detail-verdict-button--selected': isDetailActionSelected(
|
||||
flag.detail,
|
||||
'safe',
|
||||
'local',
|
||||
),
|
||||
}"
|
||||
aria-label="Local pass"
|
||||
:disabled="
|
||||
updatingDetails.has(flag.detail.id) ||
|
||||
updatingGlobalDetailKeys.has(flag.detail.key)
|
||||
"
|
||||
@click="updateLocalDetailAction(flag.detail, 'safe')"
|
||||
>
|
||||
Fail
|
||||
<CheckIcon aria-hidden="true" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
<button
|
||||
v-tooltip="getDetailActionTooltip(flag.detail, 'malware', 'local')"
|
||||
class="detail-verdict-button detail-verdict-button--unsafe"
|
||||
:class="{
|
||||
'detail-verdict-button--selected': isDetailActionSelected(
|
||||
flag.detail,
|
||||
'malware',
|
||||
'local',
|
||||
),
|
||||
}"
|
||||
aria-label="Local fail"
|
||||
:disabled="
|
||||
updatingDetails.has(flag.detail.id) ||
|
||||
updatingGlobalDetailKeys.has(flag.detail.key)
|
||||
"
|
||||
@click="updateLocalDetailAction(flag.detail, 'malware')"
|
||||
>
|
||||
<BanIcon aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-tooltip="getDetailActionTooltip(flag.detail, 'malware', 'global')"
|
||||
class="detail-verdict-button detail-verdict-button--unsafe"
|
||||
:class="{
|
||||
'detail-verdict-button--selected': isDetailActionSelected(
|
||||
flag.detail,
|
||||
'malware',
|
||||
'global',
|
||||
),
|
||||
}"
|
||||
aria-label="Global fail"
|
||||
:disabled="
|
||||
!canUpdateGlobalDetail(flag.detail) ||
|
||||
updatingGlobalDetailKeys.has(flag.detail.key) ||
|
||||
updatingDetails.has(flag.detail.id)
|
||||
"
|
||||
@click="updateGlobalDetailAction(flag.detail, 'malware')"
|
||||
>
|
||||
<ShieldAlertIcon aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -1609,4 +1944,90 @@ pre {
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.detail-verdict-action-groups {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
margin-inline-end: 0.5rem;
|
||||
}
|
||||
|
||||
.detail-verdict-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--surface-5);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-3);
|
||||
}
|
||||
|
||||
.detail-verdict-button {
|
||||
display: flex;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
border-left: 1px solid var(--surface-5);
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 0.15s ease-in-out,
|
||||
filter 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.detail-verdict-button:first-child {
|
||||
border-left: 0;
|
||||
border-start-start-radius: calc(var(--radius-md) - 1px);
|
||||
border-end-start-radius: calc(var(--radius-md) - 1px);
|
||||
}
|
||||
|
||||
.detail-verdict-button:last-child {
|
||||
border-start-end-radius: calc(var(--radius-md) - 1px);
|
||||
border-end-end-radius: calc(var(--radius-md) - 1px);
|
||||
}
|
||||
|
||||
.detail-verdict-button:hover,
|
||||
.detail-verdict-button:focus-visible {
|
||||
background: var(--surface-4);
|
||||
}
|
||||
|
||||
.detail-verdict-button--selected {
|
||||
background: var(--color-green-bg);
|
||||
box-shadow: inset 0 0 0 1px var(--color-green);
|
||||
}
|
||||
|
||||
.detail-verdict-button--selected:hover,
|
||||
.detail-verdict-button--selected:focus-visible {
|
||||
background: var(--color-green-bg);
|
||||
}
|
||||
|
||||
.detail-verdict-button:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: inset 0 0 0 2px var(--color-brand);
|
||||
}
|
||||
|
||||
.detail-verdict-button--selected:focus-visible {
|
||||
box-shadow: inset 0 0 0 2px var(--color-green);
|
||||
}
|
||||
|
||||
.detail-verdict-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.detail-verdict-button svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.detail-verdict-button--safe {
|
||||
color: var(--color-green);
|
||||
}
|
||||
|
||||
.detail-verdict-button--unsafe {
|
||||
color: var(--color-red);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -54,15 +54,21 @@
|
||||
class="message__icon backed-svg circle moderation-color"
|
||||
:class="{
|
||||
raised: raised,
|
||||
'system-message-icon': ['tech_review_entered', 'tech_review_exit_file_deleted'].includes(
|
||||
message.body.type,
|
||||
),
|
||||
'system-message-icon': [
|
||||
'tech_review_entered',
|
||||
'tech_review_exited',
|
||||
'tech_review_exit_file_deleted',
|
||||
].includes(message.body.type),
|
||||
}"
|
||||
>
|
||||
<ScaleIcon />
|
||||
</div>
|
||||
<span
|
||||
v-if="!['tech_review_entered', 'tech_review_exit_file_deleted'].includes(message.body.type)"
|
||||
v-if="
|
||||
!['tech_review_entered', 'tech_review_exited', 'tech_review_exit_file_deleted'].includes(
|
||||
message.body.type,
|
||||
)
|
||||
"
|
||||
class="message__author moderation-color"
|
||||
>
|
||||
Moderator
|
||||
@@ -100,6 +106,9 @@
|
||||
<span v-else-if="message.body.type === 'tech_review_entered'">
|
||||
The project has entered the technical review queue.
|
||||
</span>
|
||||
<span v-else-if="message.body.type === 'tech_review_exited'">
|
||||
The project has left the technical review queue as all pending traces have been resolved.
|
||||
</span>
|
||||
<span v-else-if="message.body.type === 'tech_review_exit_file_deleted'">
|
||||
The project has left the technical review queue as all files pending review were deleted by
|
||||
the user.
|
||||
@@ -214,9 +223,12 @@ const timeSincePosted = ref(formatRelativeTime(props.message.created))
|
||||
const isPrivateMessage = computed(() => {
|
||||
return (
|
||||
props.message.body.private ||
|
||||
['tech_review', 'tech_review_entered', 'tech_review_exit_file_deleted'].includes(
|
||||
props.message.body.type,
|
||||
)
|
||||
[
|
||||
'tech_review',
|
||||
'tech_review_entered',
|
||||
'tech_review_exited',
|
||||
'tech_review_exit_file_deleted',
|
||||
].includes(props.message.body.type)
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -33,7 +33,15 @@
|
||||
{{ pageStart }}-{{ pageEnd }} of {{ trace.local_trace_count }} local traces
|
||||
</p>
|
||||
</div>
|
||||
<Badge :type="trace.verdict" />
|
||||
<div class="flex shrink-0 flex-wrap items-center gap-2">
|
||||
<Badge :type="trace.verdict" />
|
||||
<ButtonStyled color="red">
|
||||
<button :disabled="isRemoving" @click="removeGlobalTrace">
|
||||
<TrashIcon aria-hidden="true" />
|
||||
Remove
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -63,13 +71,22 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { ArrowLeftIcon, HashIcon } from '@modrinth/assets'
|
||||
import { Badge, ButtonStyled, EmptyState, injectModrinthClient, Pagination } from '@modrinth/ui'
|
||||
import { ArrowLeftIcon, HashIcon, TrashIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Badge,
|
||||
ButtonStyled,
|
||||
EmptyState,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
Pagination,
|
||||
} from '@modrinth/ui'
|
||||
|
||||
import GlobalDetailLocalTraceCard from '~/components/ui/moderation/GlobalDetailLocalTraceCard.vue'
|
||||
|
||||
const client = injectModrinthClient()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const detailKey = computed(() => {
|
||||
const key = route.params.key
|
||||
@@ -80,6 +97,7 @@ useHead({ title: () => `Global trace - ${detailKey.value} - Modrinth` })
|
||||
|
||||
const localTracePageSize = 20
|
||||
const isLoading = ref(false)
|
||||
const isRemoving = ref(false)
|
||||
const loadError = ref(false)
|
||||
const currentPage = ref(1)
|
||||
const pageStartCursors = ref<(string | null)[]>([null])
|
||||
@@ -140,6 +158,34 @@ async function switchPage(page: number) {
|
||||
await loadPage(page)
|
||||
}
|
||||
|
||||
async function removeGlobalTrace() {
|
||||
if (isRemoving.value) return
|
||||
|
||||
isRemoving.value = true
|
||||
try {
|
||||
await client.labrinth.tech_review_internal.updateGlobalIssueDetails([
|
||||
{ detail_key: detailKey.value, verdict: 'pending' },
|
||||
])
|
||||
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: 'Global trace removed',
|
||||
text: 'The global verdict for this trace key has been removed.',
|
||||
})
|
||||
|
||||
await router.push('/moderation/global-traces')
|
||||
} catch (error) {
|
||||
console.error('Failed to remove global detail trace', error)
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Failed to remove global trace',
|
||||
text: 'An error occurred while removing the global trace verdict.',
|
||||
})
|
||||
} finally {
|
||||
isRemoving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
detailKey,
|
||||
() => {
|
||||
|
||||
Generated
+29
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n (\n SELECT t.id\n FROM threads t\n WHERE t.mod_id = $1\n ORDER BY t.id\n LIMIT 1\n ) AS \"thread_id: DBThreadId\",\n (\n SELECT tm.body->>'type'\n FROM threads t\n INNER JOIN threads_messages tm ON tm.thread_id = t.id\n WHERE\n t.mod_id = $1\n AND tm.body->>'type' = ANY($2::text[])\n ORDER BY tm.created DESC, tm.id DESC\n LIMIT 1\n ) AS \"last_tech_review_message_type\"\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "thread_id: DBThreadId",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "last_tech_review_message_type",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "080e2f0068c0c1c248dcfacf39798aefed70d9a61610fc7f4f1c8f291706fa4a"
|
||||
}
|
||||
Generated
-28
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n EXISTS(\n SELECT 1 FROM delphi_issue_details_with_statuses didws\n WHERE didws.project_id = $1 AND didws.status = 'pending'\n ) AS \"pending_issue_details_exist!\",\n t.id AS \"thread_id: DBThreadId\"\n FROM mods m\n INNER JOIN threads t ON t.mod_id = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "pending_issue_details_exist!",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "thread_id: DBThreadId",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "2d9e36c76a1e214c53d9dc2aa3debe1d03998be169a306b63a0ca1beaa07397f"
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT DISTINCT ON (dr.id)\n to_jsonb(dr)\n || jsonb_build_object(\n 'report_id', dr.id,\n 'file_id', to_base62(f.id),\n 'version_id', to_base62(v.id),\n 'project_id', to_base62(v.mod_id),\n 'file_name', f.filename,\n 'file_size', f.size,\n 'flag_reason', 'delphi',\n 'download_url', f.url,\n -- TODO: replace with `json_array` in Postgres 16\n\t\t\t\t'issues', (\n\t\t\t\t\tSELECT coalesce(json_agg(\n\t\t\t\t\t\tto_jsonb(dri)\n\t\t\t\t\t\t|| jsonb_build_object(\n\t\t\t\t\t\t\t-- TODO: replace with `json_array` in Postgres 16\n\t\t\t\t\t\t\t'details', (\n\t\t\t\t\t\t\t\tSELECT coalesce(jsonb_agg(\n jsonb_build_object(\n 'id', didws.id,\n 'issue_id', didws.issue_id,\n 'key', didws.key,\n 'file_path', didws.file_path,\n 'decompiled_source', didws.decompiled_source,\n 'data', didws.data,\n 'severity', didws.severity,\n 'status', didws.status\n )\n ), '[]'::jsonb)\n FROM delphi_issue_details_with_statuses didws\n WHERE didws.issue_id = dri.id\n )\n\t\t\t\t\t\t)\n\t\t\t\t\t), '[]'::json)\n\t\t\t\t\tFROM delphi_report_issues dri\n\t\t\t\t\tWHERE\n\t\t\t\t\t\tdri.report_id = dr.id\n -- see delphi.rs todo comment\n AND dri.issue_type != '__dummy'\n )\n ) AS \"data!: sqlx::types::Json<FileReport>\"\n FROM delphi_reports dr\n INNER JOIN files f ON f.id = dr.file_id\n INNER JOIN versions v ON v.id = f.version_id\n WHERE dr.id = $1\n ",
|
||||
"query": "\n SELECT DISTINCT ON (dr.id)\n to_jsonb(dr)\n || jsonb_build_object(\n 'report_id', dr.id,\n 'file_id', to_base62(f.id),\n 'version_id', to_base62(v.id),\n 'project_id', to_base62(v.mod_id),\n 'file_name', f.filename,\n 'file_size', f.size,\n 'flag_reason', 'delphi',\n 'download_url', f.url,\n -- TODO: replace with `json_array` in Postgres 16\n\t\t\t\t'issues', (\n\t\t\t\t\tSELECT coalesce(json_agg(\n\t\t\t\t\t\tto_jsonb(dri)\n\t\t\t\t\t\t|| jsonb_build_object(\n\t\t\t\t\t\t\t-- TODO: replace with `json_array` in Postgres 16\n\t\t\t\t\t\t\t'details', (\n\t\t\t\t\t\t\t\tSELECT coalesce(jsonb_agg(\n jsonb_build_object(\n 'id', didws.id,\n 'issue_id', didws.issue_id,\n 'key', didws.key,\n 'file_path', didws.file_path,\n 'decompiled_source', didws.decompiled_source,\n 'data', didws.data,\n 'severity', didws.severity,\n 'local_status', didws.local_status,\n 'global_status', didws.global_status,\n 'status', didws.status\n )\n ), '[]'::jsonb)\n FROM delphi_issue_details_with_statuses didws\n WHERE didws.issue_id = dri.id\n )\n\t\t\t\t\t\t)\n\t\t\t\t\t), '[]'::json)\n\t\t\t\t\tFROM delphi_report_issues dri\n\t\t\t\t\tWHERE\n\t\t\t\t\t\tdri.report_id = dr.id\n -- see delphi.rs todo comment\n AND dri.issue_type != '__dummy'\n )\n ) AS \"data!: sqlx::types::Json<FileReport>\"\n FROM delphi_reports dr\n INNER JOIN files f ON f.id = dr.file_id\n INNER JOIN versions v ON v.id = f.version_id\n WHERE dr.id = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -18,5 +18,5 @@
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "fe4ff6ab40fe3dc3d474c0c23c9dba514c66ac30e573fc5f4e44ed7ff360d3d6"
|
||||
"hash": "48dfc2f2bcf8917f110b7b2b142167f1525cdea2ace9e6165e5c5f596cbd9fb3"
|
||||
}
|
||||
Generated
-22
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT id AS \"thread_id: DBThreadId\"\n FROM threads\n WHERE mod_id = $1\n LIMIT 1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "thread_id: DBThreadId",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "555342b0ec9fb808f05a18aaeaf06fb61e968fb3379c9d0c7ad82c8747bd4256"
|
||||
}
|
||||
Generated
-15
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n WITH incoming AS (\n SELECT *\n FROM unnest($1::text[], $2::text[]) WITH ORDINALITY\n AS u(detail_key, verdict, ord)\n )\n INSERT INTO delphi_global_detail_verdicts (\n detail_key,\n verdict\n )\n SELECT DISTINCT ON (detail_key)\n detail_key,\n verdict::delphi_report_issue_status\n FROM incoming\n ORDER BY detail_key, ord DESC\n ON CONFLICT (detail_key)\n DO UPDATE SET verdict = EXCLUDED.verdict\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"TextArray",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "6402f359bbf733a9a2173f23c2ea222611fde49f692406123145dc6acd3dcd6a"
|
||||
}
|
||||
Generated
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n WITH incoming AS (\n SELECT *\n FROM unnest($1::text[], $2::text[]) WITH ORDINALITY\n AS u(detail_key, verdict, ord)\n ),\n latest AS (\n SELECT DISTINCT ON (detail_key)\n detail_key,\n verdict\n FROM incoming\n ORDER BY detail_key, ord DESC\n ),\n deleted AS (\n DELETE FROM delphi_global_detail_verdicts dgdv\n USING latest\n WHERE\n dgdv.detail_key = latest.detail_key\n AND latest.verdict = 'pending'\n RETURNING 1\n )\n INSERT INTO delphi_global_detail_verdicts (\n detail_key,\n verdict\n )\n SELECT\n detail_key,\n verdict::delphi_report_issue_status\n FROM latest\n WHERE verdict != 'pending'\n ON CONFLICT (detail_key)\n DO UPDATE SET verdict = EXCLUDED.verdict\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"TextArray",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "7ca85bcc45e7d53106ea75b606ce2219bd37be7bb33588179cca6f1a26e5bb23"
|
||||
}
|
||||
Generated
-22
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT EXISTS(\n SELECT 1\n FROM delphi_issue_details_with_statuses didws\n INNER JOIN delphi_report_issues dri ON dri.id = didws.issue_id\n WHERE\n didws.project_id = $1\n AND didws.status = 'pending'\n -- see delphi.rs todo comment\n AND dri.issue_type != '__dummy'\n ) AS \"is_in_tech_review!\"\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "is_in_tech_review!",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "8c80f3158fb5772adc8542cdf5419437bb8cd65723a32e587022d0c8decba68d"
|
||||
}
|
||||
+36
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n didws.id AS \"id!: DelphiReportIssueDetailsId\",\n didws.issue_id AS \"issue_id!: DelphiReportIssueId\",\n didws.key AS \"key!: String\",\n didws.jar AS \"jar?: String\",\n didws.file_path AS \"file_path!: String\",\n didws.data AS \"data!: sqlx::types::Json<HashMap<String, serde_json::Value>>\",\n didws.severity AS \"severity!: DelphiSeverity\",\n didws.status AS \"status!: DelphiStatus\"\n FROM delphi_issue_details_with_statuses didws\n WHERE didws.issue_id = ANY($1::bigint[])\n ",
|
||||
"query": "\n SELECT\n didws.id AS \"id!: DelphiReportIssueDetailsId\",\n didws.issue_id AS \"issue_id!: DelphiReportIssueId\",\n didws.key AS \"key!: String\",\n didws.jar AS \"jar?: String\",\n didws.file_path AS \"file_path!: String\",\n didws.data AS \"data!: sqlx::types::Json<HashMap<String, serde_json::Value>>\",\n didws.severity AS \"severity!: DelphiSeverity\",\n didws.local_status AS \"local_status?: DelphiStatus\",\n didws.global_status AS \"global_status?: DelphiStatus\",\n didws.status AS \"status!: DelphiStatus\"\n FROM delphi_issue_details_with_statuses didws\n WHERE didws.issue_id = ANY($1::bigint[])\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -52,6 +52,38 @@
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "local_status?: DelphiStatus",
|
||||
"type_info": {
|
||||
"Custom": {
|
||||
"name": "delphi_report_issue_status",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"pending",
|
||||
"safe",
|
||||
"unsafe"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "global_status?: DelphiStatus",
|
||||
"type_info": {
|
||||
"Custom": {
|
||||
"name": "delphi_report_issue_status",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"pending",
|
||||
"safe",
|
||||
"unsafe"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "status!: DelphiStatus",
|
||||
"type_info": {
|
||||
"Custom": {
|
||||
@@ -80,8 +112,10 @@
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "951700c659d040068dae8e2f91b82b6a3af676439b47ff76d72000ddeca0e334"
|
||||
"hash": "9070b1b6a5b1e93eb1fd1838c522fa6084ad6a5c86fafbfe8448ea7cc86f38ba"
|
||||
}
|
||||
Generated
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT DISTINCT didws.project_id AS \"project_id!: DBProjectId\"\n FROM delphi_issue_details_with_statuses didws\n WHERE didws.key = ANY($1::text[])\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "project_id!: DBProjectId",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "ba7585f55df2a4596caae265ac956eb23268f26aef664234b898b17d13c09f91"
|
||||
}
|
||||
Generated
-23
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT EXISTS(\n SELECT 1\n FROM unnest($2::text[]) AS incoming(detail_key)\n LEFT JOIN delphi_global_detail_verdicts dgdv\n ON dgdv.detail_key = incoming.detail_key\n LEFT JOIN delphi_issue_detail_verdicts didv\n ON didv.project_id = $1 AND didv.detail_key = incoming.detail_key\n WHERE dgdv.detail_key IS NULL AND didv.project_id IS NULL\n ) AS \"has_unflagged_issue_details!\"\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "has_unflagged_issue_details!",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "bab0f71c84902bac7589a30923e95cbcd76d340c21ac6116d9bd27878786bd26"
|
||||
}
|
||||
Generated
+60
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n WITH project_ids AS (\n SELECT unnest($1::bigint[]) AS project_id\n )\n SELECT\n p.project_id AS \"project_id!: DBProjectId\",\n EXISTS(\n SELECT 1\n FROM delphi_issue_details_with_statuses didws\n INNER JOIN delphi_report_issues dri ON dri.id = didws.issue_id\n WHERE\n didws.project_id = p.project_id\n AND didws.status = 'pending'\n AND dri.issue_type != $3\n ) AS \"has_pending_detail!\",\n EXISTS(\n SELECT 1\n FROM delphi_issue_details_with_statuses didws\n INNER JOIN delphi_report_issues dri ON dri.id = didws.issue_id\n WHERE\n didws.project_id = p.project_id\n AND didws.status = 'unsafe'\n AND dri.issue_type != $3\n ) AS \"has_unsafe_detail!\",\n EXISTS(\n SELECT 1\n FROM delphi_issue_details_with_statuses didws\n INNER JOIN delphi_report_issues dri ON dri.id = didws.issue_id\n WHERE\n didws.project_id = p.project_id\n AND didws.status = 'pending'\n AND dri.issue_type = $3\n ) AS \"has_dummy!\",\n (\n SELECT t.id\n FROM threads t\n WHERE t.mod_id = p.project_id\n ORDER BY t.id\n LIMIT 1\n ) AS \"thread_id: DBThreadId\",\n (\n SELECT tm.body->>'type'\n FROM threads t\n INNER JOIN threads_messages tm ON tm.thread_id = t.id\n WHERE\n t.mod_id = p.project_id\n AND tm.body->>'type' = ANY($2::text[])\n ORDER BY tm.created DESC, tm.id DESC\n LIMIT 1\n ) AS \"last_tech_review_message_type\",\n (\n SELECT dr.id\n FROM versions v\n INNER JOIN files f ON f.version_id = v.id\n INNER JOIN delphi_reports dr ON dr.file_id = f.id\n WHERE v.mod_id = p.project_id\n ORDER BY dr.created DESC, dr.id DESC\n LIMIT 1\n ) AS \"report_id: DelphiReportId\"\n FROM project_ids p\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "project_id!: DBProjectId",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "has_pending_detail!",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "has_unsafe_detail!",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "has_dummy!",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "thread_id: DBThreadId",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "last_tech_review_message_type",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "report_id: DelphiReportId",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8Array",
|
||||
"TextArray",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "bb94018c84f3809b9ad89f35ff08501addc8f203a0a152346aaebd32db52be30"
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n to_jsonb(dri)\n || jsonb_build_object(\n -- TODO: replace with `json_array` in Postgres 16\n 'details', (\n SELECT coalesce(jsonb_agg(\n jsonb_build_object(\n 'id', didws.id,\n 'issue_id', didws.issue_id,\n 'key', didws.key,\n 'file_path', didws.file_path,\n 'decompiled_source', didws.decompiled_source,\n 'data', didws.data,\n 'severity', didws.severity,\n 'status', didws.status\n )\n ), '[]'::jsonb)\n FROM delphi_issue_details_with_statuses didws\n WHERE didws.issue_id = dri.id\n )\n ) AS \"data!: sqlx::types::Json<FileIssue>\"\n FROM delphi_report_issues dri\n WHERE dri.id = $1\n ",
|
||||
"query": "\n SELECT\n to_jsonb(dri)\n || jsonb_build_object(\n -- TODO: replace with `json_array` in Postgres 16\n 'details', (\n SELECT coalesce(jsonb_agg(\n jsonb_build_object(\n 'id', didws.id,\n 'issue_id', didws.issue_id,\n 'key', didws.key,\n 'file_path', didws.file_path,\n 'decompiled_source', didws.decompiled_source,\n 'data', didws.data,\n 'severity', didws.severity,\n 'local_status', didws.local_status,\n 'global_status', didws.global_status,\n 'status', didws.status\n )\n ), '[]'::jsonb)\n FROM delphi_issue_details_with_statuses didws\n WHERE didws.issue_id = dri.id\n )\n ) AS \"data!: sqlx::types::Json<FileIssue>\"\n FROM delphi_report_issues dri\n WHERE dri.id = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -18,5 +18,5 @@
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "112bc4904c32afc9ffba30528a9c0e7b7fe259c58411afa5ef86a7ae4bde2703"
|
||||
"hash": "c3598ed9f64f7151b83d47a15fabfdcf015bda1b3c0a7f2358c7f284feb532c3"
|
||||
}
|
||||
Generated
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT DISTINCT didws.project_id AS \"project_id!: DBProjectId\"\n FROM delphi_issue_details_with_statuses didws\n INNER JOIN delphi_report_issues dri ON dri.id = didws.issue_id\n WHERE\n didws.id = ANY($1::bigint[])\n AND dri.issue_type != '__dummy'\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "project_id!: DBProjectId",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8Array"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "e3c356bd41074ba4b3474dfab5cc0349636abea5b21faf5fc18d9f69fd21db1d"
|
||||
}
|
||||
Generated
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n WITH dummy_issue AS (\n INSERT INTO delphi_report_issues (report_id, issue_type)\n VALUES ($1, $2)\n ON CONFLICT (report_id, issue_type)\n DO UPDATE SET issue_type = EXCLUDED.issue_type\n RETURNING id\n )\n INSERT INTO delphi_report_issue_details (\n issue_id,\n key,\n jar,\n file_path,\n decompiled_source,\n data,\n severity\n )\n SELECT\n id,\n '',\n NULL,\n '',\n NULL,\n '{}'::jsonb,\n 'low'::delphi_severity\n FROM dummy_issue\n WHERE NOT EXISTS (\n SELECT 1\n FROM delphi_report_issue_details drid\n WHERE drid.issue_id = dummy_issue.id\n )\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "fc6c20348de487e4039ee7d53212ecd1856bde7aa4be6e366c98cef1321a7279"
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
DROP VIEW delphi_issue_details_with_statuses;
|
||||
|
||||
CREATE VIEW delphi_issue_details_with_statuses AS
|
||||
SELECT
|
||||
drid.*,
|
||||
m.id AS project_id,
|
||||
didv.verdict AS local_status,
|
||||
dgdv.verdict AS global_status,
|
||||
COALESCE(dgdv.verdict, didv.verdict, 'pending') AS status
|
||||
FROM delphi_report_issue_details drid
|
||||
INNER JOIN delphi_report_issues dri ON dri.id = drid.issue_id
|
||||
INNER JOIN delphi_reports dr ON dr.id = dri.report_id
|
||||
INNER JOIN files f ON f.id = dr.file_id
|
||||
INNER JOIN versions v ON v.id = f.version_id
|
||||
INNER JOIN mods m ON m.id = v.mod_id
|
||||
LEFT JOIN delphi_global_detail_verdicts dgdv
|
||||
ON drid.key = dgdv.detail_key
|
||||
LEFT JOIN delphi_issue_detail_verdicts didv
|
||||
ON m.id = didv.project_id
|
||||
AND drid.key = didv.detail_key;
|
||||
@@ -254,6 +254,10 @@ pub struct ReportIssueDetail {
|
||||
pub data: HashMap<String, serde_json::Value>,
|
||||
/// How important is this issue, as flagged by Delphi?
|
||||
pub severity: DelphiSeverity,
|
||||
/// Project-local verdict for this detail, if one exists.
|
||||
pub local_status: Option<DelphiStatus>,
|
||||
/// Global verdict for this detail's key, if one exists.
|
||||
pub global_status: Option<DelphiStatus>,
|
||||
/// Has this issue detail been marked as safe or unsafe?
|
||||
pub status: DelphiStatus,
|
||||
}
|
||||
|
||||
@@ -114,6 +114,14 @@ impl From<crate::models::v3::threads::MessageBody> for LegacyMessageBody {
|
||||
associated_images: Vec::new(),
|
||||
}
|
||||
}
|
||||
crate::models::v3::threads::MessageBody::TechReviewExited => {
|
||||
LegacyMessageBody::Text {
|
||||
body: "(legacy) Exited technical review".into(),
|
||||
private: true,
|
||||
replying_to: None,
|
||||
associated_images: Vec::new(),
|
||||
}
|
||||
}
|
||||
crate::models::v3::threads::MessageBody::TechReviewExitFileDeleted => {
|
||||
LegacyMessageBody::Text {
|
||||
body: "(legacy) Exited technical review because file was deleted".into(),
|
||||
|
||||
@@ -28,8 +28,11 @@ pub struct ThreadMessage {
|
||||
pub hide_identity: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
#[derive(
|
||||
Debug, Clone, Serialize, Deserialize, utoipa::ToSchema, strum::AsRefStr,
|
||||
)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
#[strum(serialize_all = "snake_case")]
|
||||
pub enum MessageBody {
|
||||
Text {
|
||||
body: String,
|
||||
@@ -47,6 +50,7 @@ pub enum MessageBody {
|
||||
verdict: DelphiVerdict,
|
||||
},
|
||||
TechReviewEntered,
|
||||
TechReviewExited,
|
||||
TechReviewExitFileDeleted,
|
||||
ThreadClosure,
|
||||
ThreadReopen,
|
||||
@@ -62,6 +66,7 @@ impl MessageBody {
|
||||
Self::Text { private, .. } | Self::Deleted { private } => *private,
|
||||
Self::TechReview { .. }
|
||||
| Self::TechReviewEntered
|
||||
| Self::TechReviewExited
|
||||
| Self::TechReviewExitFileDeleted => true,
|
||||
Self::StatusChange { .. }
|
||||
| Self::ThreadClosure
|
||||
|
||||
@@ -13,20 +13,18 @@ use crate::{
|
||||
auth::check_is_moderator_from_headers,
|
||||
database::{
|
||||
models::{
|
||||
DBFileId, DBProjectId, DBThreadId, DelphiReportId,
|
||||
DelphiReportIssueDetailsId, DelphiReportIssueId,
|
||||
DBFileId, DBProjectId, DelphiReportId, DelphiReportIssueDetailsId,
|
||||
DelphiReportIssueId,
|
||||
delphi_report_item::{
|
||||
DBDelphiReport, DBDelphiReportIssue, DelphiSeverity,
|
||||
DelphiStatus, ReportIssueDetail,
|
||||
},
|
||||
thread_item::ThreadMessageBuilder,
|
||||
},
|
||||
redis::RedisPool,
|
||||
},
|
||||
models::{
|
||||
ids::{ProjectId, VersionId},
|
||||
pats::Scopes,
|
||||
threads::MessageBody,
|
||||
},
|
||||
queue::session::AuthQueue,
|
||||
routes::ApiError,
|
||||
@@ -34,6 +32,7 @@ use crate::{
|
||||
};
|
||||
|
||||
pub mod rescan;
|
||||
pub mod tech_review_sync;
|
||||
|
||||
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
|
||||
cfg.service(
|
||||
@@ -211,107 +210,6 @@ async fn ingest_report_deserialized(
|
||||
"Delphi found issues in file",
|
||||
);
|
||||
|
||||
let record = sqlx::query!(
|
||||
r#"
|
||||
SELECT
|
||||
EXISTS(
|
||||
SELECT 1 FROM delphi_issue_details_with_statuses didws
|
||||
WHERE didws.project_id = $1 AND didws.status = 'pending'
|
||||
) AS "pending_issue_details_exist!",
|
||||
t.id AS "thread_id: DBThreadId"
|
||||
FROM mods m
|
||||
INNER JOIN threads t ON t.mod_id = $1
|
||||
"#,
|
||||
DBProjectId::from(report.project_id) as _,
|
||||
)
|
||||
.fetch_one(&mut transaction)
|
||||
.await
|
||||
.wrap_internal_err("failed to check if pending issue details exist")?;
|
||||
|
||||
let issue_detail_keys = report
|
||||
.issues
|
||||
.values()
|
||||
.flatten()
|
||||
.map(|issue_detail| issue_detail.key.0.clone())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let has_unflagged_issue_details = sqlx::query!(
|
||||
r#"
|
||||
SELECT EXISTS(
|
||||
SELECT 1
|
||||
FROM unnest($2::text[]) AS incoming(detail_key)
|
||||
LEFT JOIN delphi_global_detail_verdicts dgdv
|
||||
ON dgdv.detail_key = incoming.detail_key
|
||||
LEFT JOIN delphi_issue_detail_verdicts didv
|
||||
ON didv.project_id = $1 AND didv.detail_key = incoming.detail_key
|
||||
WHERE dgdv.detail_key IS NULL AND didv.project_id IS NULL
|
||||
) AS "has_unflagged_issue_details!"
|
||||
"#,
|
||||
DBProjectId::from(report.project_id) as _,
|
||||
&issue_detail_keys
|
||||
)
|
||||
.fetch_one(&mut transaction)
|
||||
.await
|
||||
.wrap_internal_err("failed to check if report has unflagged issue details")?;
|
||||
|
||||
let should_enter_tech_review = !record.pending_issue_details_exist
|
||||
&& has_unflagged_issue_details.has_unflagged_issue_details;
|
||||
|
||||
if should_enter_tech_review {
|
||||
info!("File's project is entering tech review queue");
|
||||
|
||||
ThreadMessageBuilder {
|
||||
author_id: None,
|
||||
body: MessageBody::TechReviewEntered,
|
||||
thread_id: record.thread_id,
|
||||
hide_identity: false,
|
||||
}
|
||||
.insert(&mut transaction)
|
||||
.await
|
||||
.wrap_internal_err("failed to add entering tech review message")?;
|
||||
} else {
|
||||
info!(
|
||||
"File's project is not entering tech review queue (already pending or no new unflagged issue details)"
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: Currently, the way we determine if an issue is in tech review or not
|
||||
// is if it has any issue details which are pending.
|
||||
// If you mark all issue details are safe or not safe - even if you don't
|
||||
// submit the final report - the project will be taken out of tech review
|
||||
// queue, and into moderation queue.
|
||||
//
|
||||
// This is undesirable, but we can't rework the database schema to fix it
|
||||
// right now. As a hack, we add a dummy report issue which blocks the
|
||||
// project from exiting the tech review queue.
|
||||
if should_enter_tech_review {
|
||||
let dummy_issue_id = DBDelphiReportIssue {
|
||||
id: DelphiReportIssueId(0), // This will be set by the database
|
||||
report_id,
|
||||
issue_type: "__dummy".into(),
|
||||
}
|
||||
.upsert(&mut transaction)
|
||||
.await
|
||||
.wrap_internal_err("failed to upsert dummy Delphi report issue")?;
|
||||
|
||||
ReportIssueDetail {
|
||||
id: DelphiReportIssueDetailsId(0), // This will be set by the database
|
||||
issue_id: dummy_issue_id,
|
||||
key: "".into(),
|
||||
jar: None,
|
||||
file_path: "".into(),
|
||||
decompiled_source: None,
|
||||
data: HashMap::new(),
|
||||
severity: DelphiSeverity::Low,
|
||||
status: DelphiStatus::Pending,
|
||||
}
|
||||
.insert(&mut transaction)
|
||||
.await
|
||||
.wrap_internal_err(
|
||||
"failed to insert dummy Delphi report issue detail",
|
||||
)?;
|
||||
}
|
||||
|
||||
for (issue_type, issue_details) in report.issues {
|
||||
let issue_id = DBDelphiReportIssue {
|
||||
id: DelphiReportIssueId(0), // This will be set by the database
|
||||
@@ -340,6 +238,8 @@ async fn ingest_report_deserialized(
|
||||
decompiled_source: decompiled_source.cloned().flatten(),
|
||||
data: issue_detail.data,
|
||||
severity: issue_detail.severity,
|
||||
local_status: None,
|
||||
global_status: None,
|
||||
status: DelphiStatus::Pending,
|
||||
}
|
||||
.insert(&mut transaction)
|
||||
@@ -348,6 +248,13 @@ async fn ingest_report_deserialized(
|
||||
}
|
||||
}
|
||||
|
||||
tech_review_sync::sync_project_tech_review_state(
|
||||
&[DBProjectId::from(report.project_id)],
|
||||
tech_review_sync::TechReviewExitReason::Resolved,
|
||||
&mut transaction,
|
||||
)
|
||||
.await?;
|
||||
|
||||
transaction
|
||||
.commit()
|
||||
.await
|
||||
@@ -397,83 +304,6 @@ pub async fn run(
|
||||
Ok(HttpResponse::NoContent().finish())
|
||||
}
|
||||
|
||||
pub async fn is_project_in_tech_review(
|
||||
project_id: DBProjectId,
|
||||
exec: impl crate::database::Executor<'_, Database = sqlx::Postgres>,
|
||||
) -> Result<bool, ApiError> {
|
||||
let row = sqlx::query!(
|
||||
r#"
|
||||
SELECT EXISTS(
|
||||
SELECT 1
|
||||
FROM delphi_issue_details_with_statuses didws
|
||||
INNER JOIN delphi_report_issues dri ON dri.id = didws.issue_id
|
||||
WHERE
|
||||
didws.project_id = $1
|
||||
AND didws.status = 'pending'
|
||||
-- see delphi.rs todo comment
|
||||
AND dri.issue_type != '__dummy'
|
||||
) AS "is_in_tech_review!"
|
||||
"#,
|
||||
project_id as _,
|
||||
)
|
||||
.fetch_one(exec)
|
||||
.await
|
||||
.wrap_internal_err("failed to fetch project tech review state")?;
|
||||
|
||||
Ok(row.is_in_tech_review)
|
||||
}
|
||||
|
||||
pub async fn send_tech_review_exit_file_deleted_message(
|
||||
project_id: DBProjectId,
|
||||
txn: &mut crate::database::PgTransaction<'_>,
|
||||
) -> Result<(), ApiError> {
|
||||
let thread = sqlx::query!(
|
||||
r#"
|
||||
SELECT id AS "thread_id: DBThreadId"
|
||||
FROM threads
|
||||
WHERE mod_id = $1
|
||||
LIMIT 1
|
||||
"#,
|
||||
project_id as _,
|
||||
)
|
||||
.fetch_optional(&mut *txn)
|
||||
.await
|
||||
.wrap_internal_err("failed to fetch thread for tech review exit message")?;
|
||||
|
||||
if let Some(thread) = thread {
|
||||
ThreadMessageBuilder {
|
||||
author_id: None,
|
||||
body: MessageBody::TechReviewExitFileDeleted,
|
||||
thread_id: thread.thread_id,
|
||||
hide_identity: false,
|
||||
}
|
||||
.insert(txn)
|
||||
.await
|
||||
.wrap_internal_err("failed to add tech review exit message")?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn send_tech_review_exit_file_deleted_message_if_exited(
|
||||
project_id: DBProjectId,
|
||||
was_in_tech_review: bool,
|
||||
txn: &mut crate::database::PgTransaction<'_>,
|
||||
) -> Result<(), ApiError> {
|
||||
if !was_in_tech_review {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let is_still_in_tech_review =
|
||||
is_project_in_tech_review(project_id, &mut *txn).await?;
|
||||
|
||||
if !is_still_in_tech_review {
|
||||
send_tech_review_exit_file_deleted_message(project_id, txn).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run Delphi.
|
||||
#[utoipa::path(
|
||||
context_path = "/delphi",
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
//! Synchronizes moderation thread messages and dummy queue blockers with the
|
||||
//! current computed tech review state of affected projects.
|
||||
//!
|
||||
//! When a project has a Delphi report submitted for it, or when a moderator
|
||||
//! updates one of its issue details' rows (like flagging a detail as *globally*
|
||||
//! safe or unsafe), we will need to recheck if the project still belongs in the
|
||||
//! tech review queue or if it needs to exit now.
|
||||
//!
|
||||
//! Side-note: "entering the queue" or "exiting the queue" right now just means
|
||||
//! adding a new message to the project's moderation thread which indicates if
|
||||
//! it entered/exited. In the future this should be replaced with a more proper
|
||||
//! audit log table, or "is project currently in tech review" table.
|
||||
//!
|
||||
//! A project is considered to need tech review when it has at least one
|
||||
//! non-dummy issue detail whose effective status is pending or unsafe, or when
|
||||
//! it already has a dummy pending detail blocking the final review submission.
|
||||
//! Effective status is just the local detail's verdict (from
|
||||
//! `delphi_issue_detail_verdicts`), or if it's null then the global verdict for
|
||||
//! the same `drid.key` (from `delphi_global_detail_verdicts`).
|
||||
//!
|
||||
//! Some examples of how this behavior manifests: let's assume you have projects
|
||||
//! _A_ and _B_ currently in tech review. They each have one (unresolved) issue
|
||||
//! detail with key _K_.
|
||||
//! - If you mark _K_ on _A_ as locally safe/unsafe, then _A_ is fully resolved,
|
||||
//! but we still have the `__dummy` detail, which means it's still in the
|
||||
//! queue until the moderator submits the actual report. _B_ is entirely
|
||||
//! unaffected.
|
||||
//! - If you mark _K_ on _A_ as globally safe, then _A_ and _B_ both get fully
|
||||
//! resolved, but both still have the `__dummy` detail, so they also still
|
||||
//! need the final report to be submitted by the moderator.
|
||||
//!
|
||||
//! In practice, this means that some projects may have e.g. "100/100 traces
|
||||
//! are safe" reported, but they will just be waiting for final moderator
|
||||
//! approval.
|
||||
//!
|
||||
//! The logic for checking whether a project is now in tech review or not, and
|
||||
//! sending the appropriate message, is complex! That's why this module exists:
|
||||
//! to act as a single chokepoint which (correctly) syncs all the state, instead
|
||||
//! of having each mutation run its own ad-hoc update logic.
|
||||
|
||||
use itertools::Itertools;
|
||||
|
||||
use crate::{
|
||||
database::{
|
||||
PgTransaction,
|
||||
models::{
|
||||
DBProjectId, DBThreadId, DelphiReportId,
|
||||
delphi_report_item::DelphiVerdict,
|
||||
thread_item::ThreadMessageBuilder,
|
||||
},
|
||||
},
|
||||
models::threads::MessageBody,
|
||||
routes::ApiError,
|
||||
util::error::Context,
|
||||
};
|
||||
|
||||
const DUMMY_ISSUE_TYPE: &str = "__dummy";
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum TechReviewExitReason {
|
||||
Resolved,
|
||||
FileDeleted,
|
||||
}
|
||||
|
||||
struct ProjectTechReviewState {
|
||||
has_pending_detail: bool,
|
||||
has_unsafe_detail: bool,
|
||||
has_dummy: bool,
|
||||
thread_id: Option<DBThreadId>,
|
||||
report_id: Option<DelphiReportId>,
|
||||
last_tech_review_message_type: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn sync_project_tech_review_state(
|
||||
project_ids: &[DBProjectId],
|
||||
exit_reason: TechReviewExitReason,
|
||||
txn: &mut PgTransaction<'_>,
|
||||
) -> Result<(), ApiError> {
|
||||
let project_ids = project_ids.iter().copied().unique().collect::<Vec<_>>();
|
||||
if project_ids.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let project_ids_raw = project_ids.iter().map(|id| id.0).collect::<Vec<_>>();
|
||||
let tech_review_message_types = tech_review_message_types();
|
||||
|
||||
let rows = sqlx::query!(
|
||||
r#"
|
||||
WITH project_ids AS (
|
||||
SELECT unnest($1::bigint[]) AS project_id
|
||||
)
|
||||
SELECT
|
||||
p.project_id AS "project_id!: DBProjectId",
|
||||
EXISTS(
|
||||
SELECT 1
|
||||
FROM delphi_issue_details_with_statuses didws
|
||||
INNER JOIN delphi_report_issues dri ON dri.id = didws.issue_id
|
||||
WHERE
|
||||
didws.project_id = p.project_id
|
||||
AND didws.status = 'pending'
|
||||
AND dri.issue_type != $3
|
||||
) AS "has_pending_detail!",
|
||||
EXISTS(
|
||||
SELECT 1
|
||||
FROM delphi_issue_details_with_statuses didws
|
||||
INNER JOIN delphi_report_issues dri ON dri.id = didws.issue_id
|
||||
WHERE
|
||||
didws.project_id = p.project_id
|
||||
AND didws.status = 'unsafe'
|
||||
AND dri.issue_type != $3
|
||||
) AS "has_unsafe_detail!",
|
||||
EXISTS(
|
||||
SELECT 1
|
||||
FROM delphi_issue_details_with_statuses didws
|
||||
INNER JOIN delphi_report_issues dri ON dri.id = didws.issue_id
|
||||
WHERE
|
||||
didws.project_id = p.project_id
|
||||
AND didws.status = 'pending'
|
||||
AND dri.issue_type = $3
|
||||
) AS "has_dummy!",
|
||||
(
|
||||
SELECT t.id
|
||||
FROM threads t
|
||||
WHERE t.mod_id = p.project_id
|
||||
ORDER BY t.id
|
||||
LIMIT 1
|
||||
) AS "thread_id: DBThreadId",
|
||||
(
|
||||
SELECT tm.body->>'type'
|
||||
FROM threads t
|
||||
INNER JOIN threads_messages tm ON tm.thread_id = t.id
|
||||
WHERE
|
||||
t.mod_id = p.project_id
|
||||
AND tm.body->>'type' = ANY($2::text[])
|
||||
ORDER BY tm.created DESC, tm.id DESC
|
||||
LIMIT 1
|
||||
) AS "last_tech_review_message_type",
|
||||
(
|
||||
SELECT dr.id
|
||||
FROM versions v
|
||||
INNER JOIN files f ON f.version_id = v.id
|
||||
INNER JOIN delphi_reports dr ON dr.file_id = f.id
|
||||
WHERE v.mod_id = p.project_id
|
||||
ORDER BY dr.created DESC, dr.id DESC
|
||||
LIMIT 1
|
||||
) AS "report_id: DelphiReportId"
|
||||
FROM project_ids p
|
||||
"#,
|
||||
&project_ids_raw,
|
||||
&tech_review_message_types,
|
||||
DUMMY_ISSUE_TYPE,
|
||||
)
|
||||
.fetch_all(&mut *txn)
|
||||
.await
|
||||
.wrap_internal_err("failed to fetch project tech review state")?;
|
||||
|
||||
for row in rows {
|
||||
let state = ProjectTechReviewState {
|
||||
has_pending_detail: row.has_pending_detail,
|
||||
has_unsafe_detail: row.has_unsafe_detail,
|
||||
has_dummy: row.has_dummy,
|
||||
thread_id: row.thread_id,
|
||||
report_id: row.report_id,
|
||||
last_tech_review_message_type: row.last_tech_review_message_type,
|
||||
};
|
||||
|
||||
sync_one_project_tech_review_state(state, exit_reason, txn).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn sync_detail_key_tech_review_state(
|
||||
detail_keys: &[String],
|
||||
exit_reason: TechReviewExitReason,
|
||||
txn: &mut PgTransaction<'_>,
|
||||
) -> Result<(), ApiError> {
|
||||
let detail_keys = detail_keys.iter().cloned().unique().collect::<Vec<_>>();
|
||||
|
||||
if detail_keys.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let rows = sqlx::query!(
|
||||
r#"
|
||||
SELECT DISTINCT didws.project_id AS "project_id!: DBProjectId"
|
||||
FROM delphi_issue_details_with_statuses didws
|
||||
WHERE didws.key = ANY($1::text[])
|
||||
"#,
|
||||
&detail_keys,
|
||||
)
|
||||
.fetch_all(&mut *txn)
|
||||
.await
|
||||
.wrap_internal_err("failed to fetch projects affected by detail keys")?;
|
||||
|
||||
let project_ids = rows
|
||||
.into_iter()
|
||||
.map(|row| row.project_id)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
sync_project_tech_review_state(&project_ids, exit_reason, txn).await
|
||||
}
|
||||
|
||||
pub async fn sync_deleted_project_tech_review_exit(
|
||||
project_id: DBProjectId,
|
||||
txn: &mut PgTransaction<'_>,
|
||||
) -> Result<(), ApiError> {
|
||||
let tech_review_message_types = tech_review_message_types();
|
||||
|
||||
let row = sqlx::query!(
|
||||
r#"
|
||||
SELECT
|
||||
(
|
||||
SELECT t.id
|
||||
FROM threads t
|
||||
WHERE t.mod_id = $1
|
||||
ORDER BY t.id
|
||||
LIMIT 1
|
||||
) AS "thread_id: DBThreadId",
|
||||
(
|
||||
SELECT tm.body->>'type'
|
||||
FROM threads t
|
||||
INNER JOIN threads_messages tm ON tm.thread_id = t.id
|
||||
WHERE
|
||||
t.mod_id = $1
|
||||
AND tm.body->>'type' = ANY($2::text[])
|
||||
ORDER BY tm.created DESC, tm.id DESC
|
||||
LIMIT 1
|
||||
) AS "last_tech_review_message_type"
|
||||
"#,
|
||||
project_id as DBProjectId,
|
||||
&tech_review_message_types,
|
||||
)
|
||||
.fetch_one(&mut *txn)
|
||||
.await
|
||||
.wrap_internal_err("failed to fetch deleted project tech review state")?;
|
||||
|
||||
if let Some(thread_id) = row.thread_id
|
||||
&& should_send_exit(row.last_tech_review_message_type.as_deref())
|
||||
{
|
||||
insert_exit_message(thread_id, TechReviewExitReason::FileDeleted, txn)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn sync_one_project_tech_review_state(
|
||||
state: ProjectTechReviewState,
|
||||
exit_reason: TechReviewExitReason,
|
||||
txn: &mut PgTransaction<'_>,
|
||||
) -> Result<(), ApiError> {
|
||||
let needs_tech_review =
|
||||
state.has_pending_detail || state.has_unsafe_detail || state.has_dummy;
|
||||
|
||||
if needs_tech_review {
|
||||
if (state.has_pending_detail || state.has_unsafe_detail)
|
||||
&& !state.has_dummy
|
||||
&& let Some(report_id) = state.report_id
|
||||
{
|
||||
// TODO: Currently, the queue query determines whether a project is
|
||||
// in tech review by checking whether it has any pending issue
|
||||
// details. If all visible issue details are marked safe or unsafe
|
||||
// before the final report is submitted, the project would otherwise
|
||||
// leave the tech review queue without a final tech review verdict
|
||||
// message.
|
||||
//
|
||||
// This should be replaced with explicit tech review state, such as
|
||||
// an append-only project tech review event table where the latest
|
||||
// enter/exit event is the current state. Until then, this dummy
|
||||
// issue detail acts as the pending queue blocker.
|
||||
ensure_dummy_issue_detail(report_id, txn).await?;
|
||||
}
|
||||
|
||||
if let Some(thread_id) = state.thread_id
|
||||
&& state.last_tech_review_message_type.as_deref()
|
||||
!= Some(MessageBody::TechReviewEntered.as_ref())
|
||||
{
|
||||
ThreadMessageBuilder {
|
||||
author_id: None,
|
||||
body: MessageBody::TechReviewEntered,
|
||||
thread_id,
|
||||
hide_identity: false,
|
||||
}
|
||||
.insert(txn)
|
||||
.await
|
||||
.wrap_internal_err("failed to add entering tech review message")?;
|
||||
}
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if matches!(exit_reason, TechReviewExitReason::Resolved)
|
||||
&& state.last_tech_review_message_type.as_deref()
|
||||
== Some(MessageBody::TechReviewEntered.as_ref())
|
||||
{
|
||||
if let Some(report_id) = state.report_id {
|
||||
ensure_dummy_issue_detail(report_id, txn).await?;
|
||||
}
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(thread_id) = state.thread_id
|
||||
&& should_send_exit(state.last_tech_review_message_type.as_deref())
|
||||
{
|
||||
insert_exit_message(thread_id, exit_reason, txn).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn should_send_exit(last_tech_review_message_type: Option<&str>) -> bool {
|
||||
matches!(last_tech_review_message_type, Some(message_type) if !matches!(
|
||||
message_type,
|
||||
message_type if message_type == MessageBody::TechReviewExited.as_ref()
|
||||
|| message_type == MessageBody::TechReviewExitFileDeleted.as_ref()
|
||||
|| message_type == tech_review_completed_message_type()
|
||||
))
|
||||
}
|
||||
|
||||
async fn insert_exit_message(
|
||||
thread_id: DBThreadId,
|
||||
exit_reason: TechReviewExitReason,
|
||||
txn: &mut PgTransaction<'_>,
|
||||
) -> Result<(), ApiError> {
|
||||
let body = match exit_reason {
|
||||
TechReviewExitReason::Resolved => MessageBody::TechReviewExited,
|
||||
TechReviewExitReason::FileDeleted => {
|
||||
MessageBody::TechReviewExitFileDeleted
|
||||
}
|
||||
};
|
||||
|
||||
ThreadMessageBuilder {
|
||||
author_id: None,
|
||||
body,
|
||||
thread_id,
|
||||
hide_identity: false,
|
||||
}
|
||||
.insert(txn)
|
||||
.await
|
||||
.wrap_internal_err("failed to add exiting tech review message")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn ensure_dummy_issue_detail(
|
||||
report_id: DelphiReportId,
|
||||
txn: &mut PgTransaction<'_>,
|
||||
) -> Result<(), ApiError> {
|
||||
sqlx::query!(
|
||||
r#"
|
||||
WITH dummy_issue AS (
|
||||
INSERT INTO delphi_report_issues (report_id, issue_type)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (report_id, issue_type)
|
||||
DO UPDATE SET issue_type = EXCLUDED.issue_type
|
||||
RETURNING id
|
||||
)
|
||||
INSERT INTO delphi_report_issue_details (
|
||||
issue_id,
|
||||
key,
|
||||
jar,
|
||||
file_path,
|
||||
decompiled_source,
|
||||
data,
|
||||
severity
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
'',
|
||||
NULL,
|
||||
'',
|
||||
NULL,
|
||||
'{}'::jsonb,
|
||||
'low'::delphi_severity
|
||||
FROM dummy_issue
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM delphi_report_issue_details drid
|
||||
WHERE drid.issue_id = dummy_issue.id
|
||||
)
|
||||
"#,
|
||||
report_id as DelphiReportId,
|
||||
DUMMY_ISSUE_TYPE,
|
||||
)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.wrap_internal_err("failed to ensure dummy Delphi report issue detail")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn tech_review_message_types() -> Vec<String> {
|
||||
[
|
||||
MessageBody::TechReviewEntered.as_ref(),
|
||||
MessageBody::TechReviewExited.as_ref(),
|
||||
MessageBody::TechReviewExitFileDeleted.as_ref(),
|
||||
tech_review_completed_message_type(),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|message_type| message_type.to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn tech_review_completed_message_type() -> &'static str {
|
||||
MessageBody::TechReview {
|
||||
verdict: DelphiVerdict::Safe,
|
||||
}
|
||||
.as_ref()
|
||||
}
|
||||
@@ -31,7 +31,13 @@ use crate::{
|
||||
threads::{MessageBody, Thread},
|
||||
},
|
||||
queue::session::AuthQueue,
|
||||
routes::{ApiError, internal::moderation::Ownership},
|
||||
routes::{
|
||||
ApiError,
|
||||
internal::{
|
||||
delphi::tech_review_sync::{self, TechReviewExitReason},
|
||||
moderation::Ownership,
|
||||
},
|
||||
},
|
||||
search::SearchState,
|
||||
util::error::Context,
|
||||
};
|
||||
@@ -238,6 +244,8 @@ pub async fn get_issue(
|
||||
'decompiled_source', didws.decompiled_source,
|
||||
'data', didws.data,
|
||||
'severity', didws.severity,
|
||||
'local_status', didws.local_status,
|
||||
'global_status', didws.global_status,
|
||||
'status', didws.status
|
||||
)
|
||||
), '[]'::jsonb)
|
||||
@@ -313,6 +321,8 @@ pub async fn get_report(
|
||||
'decompiled_source', didws.decompiled_source,
|
||||
'data', didws.data,
|
||||
'severity', didws.severity,
|
||||
'local_status', didws.local_status,
|
||||
'global_status', didws.global_status,
|
||||
'status', didws.status
|
||||
)
|
||||
), '[]'::jsonb)
|
||||
@@ -513,6 +523,8 @@ async fn fetch_project_reports(
|
||||
didws.file_path AS "file_path!: String",
|
||||
didws.data AS "data!: sqlx::types::Json<HashMap<String, serde_json::Value>>",
|
||||
didws.severity AS "severity!: DelphiSeverity",
|
||||
didws.local_status AS "local_status?: DelphiStatus",
|
||||
didws.global_status AS "global_status?: DelphiStatus",
|
||||
didws.status AS "status!: DelphiStatus"
|
||||
FROM delphi_issue_details_with_statuses didws
|
||||
WHERE didws.issue_id = ANY($1::bigint[])
|
||||
@@ -571,6 +583,8 @@ async fn fetch_project_reports(
|
||||
decompiled_source: None,
|
||||
data: d.data.0,
|
||||
severity: d.severity,
|
||||
local_status: d.local_status,
|
||||
global_status: d.global_status,
|
||||
status: d.status,
|
||||
})
|
||||
.into_group_map_by(|d| d.issue_id);
|
||||
@@ -1176,7 +1190,9 @@ pub struct UpdateGlobalIssue {
|
||||
/// Key of the issue detail to update globally.
|
||||
pub detail_key: String,
|
||||
/// What the moderator has decided the outcome of this issue is globally.
|
||||
pub verdict: DelphiVerdict,
|
||||
///
|
||||
/// `pending` removes the global verdict for this issue detail key.
|
||||
pub verdict: DelphiStatus,
|
||||
}
|
||||
|
||||
/// Update technical review issue details.
|
||||
@@ -1296,6 +1312,35 @@ pub async fn update_issue_details(
|
||||
return Err(ApiError::Request(eyre!("issue detail does not exist")));
|
||||
}
|
||||
|
||||
let affected_projects = sqlx::query!(
|
||||
r#"
|
||||
SELECT DISTINCT didws.project_id AS "project_id!: DBProjectId"
|
||||
FROM delphi_issue_details_with_statuses didws
|
||||
INNER JOIN delphi_report_issues dri ON dri.id = didws.issue_id
|
||||
WHERE
|
||||
didws.id = ANY($1::bigint[])
|
||||
AND dri.issue_type != '__dummy'
|
||||
"#,
|
||||
&detail_ids,
|
||||
)
|
||||
.fetch_all(&mut txn)
|
||||
.await
|
||||
.wrap_internal_err(
|
||||
"failed to fetch projects affected by issue detail updates",
|
||||
)?;
|
||||
|
||||
let affected_project_ids = affected_projects
|
||||
.into_iter()
|
||||
.map(|row| row.project_id)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
tech_review_sync::sync_project_tech_review_state(
|
||||
&affected_project_ids,
|
||||
TechReviewExitReason::Resolved,
|
||||
&mut txn,
|
||||
)
|
||||
.await?;
|
||||
|
||||
txn.commit()
|
||||
.await
|
||||
.wrap_internal_err("failed to commit transaction")?;
|
||||
@@ -1305,7 +1350,8 @@ pub async fn update_issue_details(
|
||||
|
||||
/// Update global technical review issue detail verdicts.
|
||||
///
|
||||
/// This marks every issue detail with a matching key as safe or unsafe.
|
||||
/// This marks every issue detail with a matching key as safe or unsafe, or
|
||||
/// unsets the global verdict with `pending`.
|
||||
#[utoipa::path(
|
||||
context_path = "/moderation/tech-review",
|
||||
tag = "moderation",
|
||||
@@ -1346,8 +1392,9 @@ pub async fn update_global_issue_details(
|
||||
let verdicts = updates
|
||||
.iter()
|
||||
.map(|u| match u.verdict {
|
||||
DelphiVerdict::Safe => "safe".to_string(),
|
||||
DelphiVerdict::Unsafe => "unsafe".to_string(),
|
||||
DelphiStatus::Safe => "safe".to_string(),
|
||||
DelphiStatus::Unsafe => "unsafe".to_string(),
|
||||
DelphiStatus::Pending => "pending".to_string(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
@@ -1362,16 +1409,31 @@ pub async fn update_global_issue_details(
|
||||
SELECT *
|
||||
FROM unnest($1::text[], $2::text[]) WITH ORDINALITY
|
||||
AS u(detail_key, verdict, ord)
|
||||
),
|
||||
latest AS (
|
||||
SELECT DISTINCT ON (detail_key)
|
||||
detail_key,
|
||||
verdict
|
||||
FROM incoming
|
||||
ORDER BY detail_key, ord DESC
|
||||
),
|
||||
deleted AS (
|
||||
DELETE FROM delphi_global_detail_verdicts dgdv
|
||||
USING latest
|
||||
WHERE
|
||||
dgdv.detail_key = latest.detail_key
|
||||
AND latest.verdict = 'pending'
|
||||
RETURNING 1
|
||||
)
|
||||
INSERT INTO delphi_global_detail_verdicts (
|
||||
detail_key,
|
||||
verdict
|
||||
)
|
||||
SELECT DISTINCT ON (detail_key)
|
||||
SELECT
|
||||
detail_key,
|
||||
verdict::delphi_report_issue_status
|
||||
FROM incoming
|
||||
ORDER BY detail_key, ord DESC
|
||||
FROM latest
|
||||
WHERE verdict != 'pending'
|
||||
ON CONFLICT (detail_key)
|
||||
DO UPDATE SET verdict = EXCLUDED.verdict
|
||||
"#,
|
||||
@@ -1382,6 +1444,13 @@ pub async fn update_global_issue_details(
|
||||
.await
|
||||
.wrap_internal_err("failed to update global issue details")?;
|
||||
|
||||
tech_review_sync::sync_detail_key_tech_review_state(
|
||||
&detail_keys,
|
||||
TechReviewExitReason::Resolved,
|
||||
&mut txn,
|
||||
)
|
||||
.await?;
|
||||
|
||||
txn.commit()
|
||||
.await
|
||||
.wrap_internal_err("failed to commit transaction")?;
|
||||
|
||||
@@ -2746,17 +2746,11 @@ pub async fn project_delete_internal(
|
||||
.begin()
|
||||
.await
|
||||
.wrap_internal_err("failed to start transaction")?;
|
||||
let was_in_tech_review =
|
||||
delphi::is_project_in_tech_review(project.inner.id, &mut transaction)
|
||||
.await?;
|
||||
|
||||
if was_in_tech_review {
|
||||
delphi::send_tech_review_exit_file_deleted_message(
|
||||
project.inner.id,
|
||||
&mut transaction,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
delphi::tech_review_sync::sync_deleted_project_tech_review_exit(
|
||||
project.inner.id,
|
||||
&mut transaction,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let context = ImageContext::Project {
|
||||
project_id: Some(project.inner.id.into()),
|
||||
|
||||
@@ -910,9 +910,6 @@ pub async fn delete_file(
|
||||
}
|
||||
|
||||
let mut transaction = pool.begin().await?;
|
||||
let was_in_tech_review =
|
||||
delphi::is_project_in_tech_review(row.project_id, &mut transaction)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
@@ -937,9 +934,9 @@ pub async fn delete_file(
|
||||
database::models::version_item::cleanup_unused_attribution_files_and_groups(&mut transaction)
|
||||
.await?;
|
||||
|
||||
delphi::send_tech_review_exit_file_deleted_message_if_exited(
|
||||
row.project_id,
|
||||
was_in_tech_review,
|
||||
delphi::tech_review_sync::sync_project_tech_review_state(
|
||||
&[row.project_id],
|
||||
delphi::tech_review_sync::TechReviewExitReason::FileDeleted,
|
||||
&mut transaction,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -1217,11 +1217,6 @@ pub async fn version_delete(
|
||||
}
|
||||
|
||||
let mut transaction = pool.begin().await?;
|
||||
let was_in_tech_review = delphi::is_project_in_tech_review(
|
||||
version.inner.project_id,
|
||||
&mut transaction,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let context = ImageContext::Version {
|
||||
version_id: Some(version.inner.id.into()),
|
||||
@@ -1242,9 +1237,9 @@ pub async fn version_delete(
|
||||
)
|
||||
.await?;
|
||||
|
||||
delphi::send_tech_review_exit_file_deleted_message_if_exited(
|
||||
version.inner.project_id,
|
||||
was_in_tech_review,
|
||||
delphi::tech_review_sync::sync_project_tech_review_state(
|
||||
&[version.inner.project_id],
|
||||
delphi::tech_review_sync::TechReviewExitReason::FileDeleted,
|
||||
&mut transaction,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -1833,6 +1833,19 @@ export namespace Labrinth {
|
||||
new_status: Projects.v2.ProjectStatus
|
||||
old_status: Projects.v2.ProjectStatus
|
||||
}
|
||||
| {
|
||||
type: 'tech_review'
|
||||
verdict: 'safe' | 'unsafe'
|
||||
}
|
||||
| {
|
||||
type: 'tech_review_entered'
|
||||
}
|
||||
| {
|
||||
type: 'tech_review_exited'
|
||||
}
|
||||
| {
|
||||
type: 'tech_review_exit_file_deleted'
|
||||
}
|
||||
| {
|
||||
type: 'thread_closure'
|
||||
}
|
||||
@@ -2234,7 +2247,7 @@ export namespace Labrinth {
|
||||
|
||||
export type UpdateGlobalIssueRequest = {
|
||||
detail_key: string
|
||||
verdict: 'safe' | 'unsafe'
|
||||
verdict: DelphiReportIssueStatus
|
||||
}
|
||||
|
||||
export type SearchGlobalIssueDetailsRequest = {
|
||||
@@ -2343,6 +2356,8 @@ export namespace Labrinth {
|
||||
decompiled_source: string | null
|
||||
data: Record<string, unknown>
|
||||
severity: DelphiSeverity
|
||||
local_status: DelphiReportIssueStatus | null
|
||||
global_status: DelphiReportIssueStatus | null
|
||||
status: DelphiReportIssueStatus
|
||||
}
|
||||
|
||||
@@ -2391,6 +2406,19 @@ export namespace Labrinth {
|
||||
new_status: Projects.v2.ProjectStatus
|
||||
old_status: Projects.v2.ProjectStatus
|
||||
}
|
||||
| {
|
||||
type: 'tech_review'
|
||||
verdict: 'safe' | 'unsafe'
|
||||
}
|
||||
| {
|
||||
type: 'tech_review_entered'
|
||||
}
|
||||
| {
|
||||
type: 'tech_review_exited'
|
||||
}
|
||||
| {
|
||||
type: 'tech_review_exit_file_deleted'
|
||||
}
|
||||
| {
|
||||
type: 'thread_closure'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user