Skip to content

Commit 97af744

Browse files
[dev] [tofikwest] tofik/github-evidence-check-pr-and-json (#1986)
* feat(evidence): add EvidenceJsonView component for JSON evidence display * chore: remove unused react-json-view dependency and related code --------- Co-authored-by: Tofik Hasanov <annexcies@gmail.com>
1 parent bb39d6f commit 97af744

9 files changed

Lines changed: 406 additions & 19 deletions

File tree

apps/app/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
"@types/canvas-confetti": "^1.9.0",
6262
"@types/react-syntax-highlighter": "^15.5.13",
6363
"@types/three": "^0.180.0",
64+
"@uiw/react-json-view": "^2.0.0-alpha.40",
6465
"@uploadthing/react": "^7.3.0",
6566
"@upstash/ratelimit": "^2.0.5",
6667
"@vercel/analytics": "^1.5.0",
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
'use client';
2+
3+
import JsonView from '@uiw/react-json-view';
4+
import { Download } from 'lucide-react';
5+
import { useCallback, useMemo } from 'react';
6+
7+
interface EvidenceJsonViewProps {
8+
evidence: Record<string, unknown>;
9+
organizationName?: string;
10+
automationName?: string;
11+
}
12+
13+
/**
14+
* Sanitizes a value for safe JSON serialization.
15+
* Handles edge cases: functions, symbols, circular refs, undefined, NaN, Infinity,
16+
* Map, Set, RegExp, Error objects, and getters that throw.
17+
*/
18+
const sanitizeForJson = (obj: unknown, seen = new WeakSet()): unknown => {
19+
// Handle null/undefined
20+
if (obj === null || obj === undefined) return null;
21+
22+
// Handle functions
23+
if (typeof obj === 'function') return '[Function]';
24+
25+
// Handle symbols
26+
if (typeof obj === 'symbol') return obj.toString();
27+
28+
// Handle bigint
29+
if (typeof obj === 'bigint') return obj.toString();
30+
31+
// Handle numbers - NaN and Infinity are not valid JSON
32+
if (typeof obj === 'number') {
33+
if (Number.isNaN(obj)) return null;
34+
if (!Number.isFinite(obj)) return null;
35+
return obj;
36+
}
37+
38+
// Handle strings and booleans - pass through
39+
if (typeof obj === 'string' || typeof obj === 'boolean') {
40+
return obj;
41+
}
42+
43+
// Handle Date objects
44+
if (obj instanceof Date) {
45+
return Number.isNaN(obj.getTime()) ? null : obj.toISOString();
46+
}
47+
48+
// Handle RegExp
49+
if (obj instanceof RegExp) {
50+
return obj.toString();
51+
}
52+
53+
// Handle Error objects - extract useful info
54+
if (obj instanceof Error) {
55+
return {
56+
name: obj.name,
57+
message: obj.message,
58+
stack: obj.stack,
59+
};
60+
}
61+
62+
// Handle Map - convert to object
63+
if (obj instanceof Map) {
64+
if (seen.has(obj)) return '[Circular Reference]';
65+
seen.add(obj);
66+
const result: Record<string, unknown> = {};
67+
obj.forEach((value, key) => {
68+
const keyStr = typeof key === 'string' ? key : String(key);
69+
result[keyStr] = sanitizeForJson(value, seen);
70+
});
71+
return result;
72+
}
73+
74+
// Handle Set - convert to array
75+
if (obj instanceof Set) {
76+
if (seen.has(obj)) return '[Circular Reference]';
77+
seen.add(obj);
78+
return Array.from(obj).map((item) => sanitizeForJson(item, seen));
79+
}
80+
81+
// Handle arrays - check for circular reference first
82+
if (Array.isArray(obj)) {
83+
if (seen.has(obj)) {
84+
return '[Circular Reference]';
85+
}
86+
seen.add(obj);
87+
return obj.map((item) => sanitizeForJson(item, seen));
88+
}
89+
90+
// Handle plain objects
91+
if (typeof obj === 'object') {
92+
// Detect circular reference
93+
if (seen.has(obj)) {
94+
return '[Circular Reference]';
95+
}
96+
seen.add(obj);
97+
98+
const result: Record<string, unknown> = {};
99+
100+
// Use try/catch to handle getters that might throw
101+
let entries: [string, unknown][];
102+
try {
103+
entries = Object.entries(obj);
104+
} catch {
105+
return '[Object with inaccessible properties]';
106+
}
107+
108+
for (const [key, value] of entries) {
109+
try {
110+
result[key] = sanitizeForJson(value, seen);
111+
} catch {
112+
result[key] = '[Error accessing property]';
113+
}
114+
}
115+
return result;
116+
}
117+
118+
// Fallback for any unknown types
119+
return String(obj);
120+
};
121+
122+
/**
123+
* Formats a string to be safe for filenames
124+
*/
125+
const toSafeFilename = (str: string): string => {
126+
return str
127+
.toLowerCase()
128+
.replace(/[^a-z0-9]+/g, '_')
129+
.replace(/^_+|_+$/g, '')
130+
.slice(0, 50);
131+
};
132+
133+
/**
134+
* Gets a short date string for the filename (YYYY-MM-DD)
135+
*/
136+
const getShortDate = (): string => {
137+
const now = new Date();
138+
return now.toISOString().split('T')[0];
139+
};
140+
141+
export function EvidenceJsonView({
142+
evidence,
143+
organizationName = 'organization',
144+
automationName = 'automation',
145+
}: EvidenceJsonViewProps) {
146+
// Sanitize evidence for safe rendering and download
147+
const sanitizedEvidence = useMemo(() => {
148+
try {
149+
return sanitizeForJson(evidence) as Record<string, unknown>;
150+
} catch {
151+
return { error: 'Failed to process evidence data' };
152+
}
153+
}, [evidence]);
154+
155+
// Generate filename: {orgName}_evidence_{automationName}_{date}.json
156+
const generateFilename = useCallback(() => {
157+
const orgPart = toSafeFilename(organizationName);
158+
const automationPart = toSafeFilename(automationName);
159+
const datePart = getShortDate();
160+
return `${orgPart}_evidence_${automationPart}_${datePart}.json`;
161+
}, [organizationName, automationName]);
162+
163+
const handleDownload = useCallback(() => {
164+
try {
165+
const jsonString = JSON.stringify(sanitizedEvidence, null, 2);
166+
const blob = new Blob([jsonString], { type: 'application/json' });
167+
const url = URL.createObjectURL(blob);
168+
const link = document.createElement('a');
169+
link.href = url;
170+
link.download = generateFilename();
171+
document.body.appendChild(link);
172+
link.click();
173+
document.body.removeChild(link);
174+
URL.revokeObjectURL(url);
175+
} catch {
176+
console.error('Failed to download evidence JSON');
177+
}
178+
}, [sanitizedEvidence, generateFilename]);
179+
180+
// Check if evidence is empty or invalid
181+
const hasValidEvidence = useMemo(() => {
182+
return (
183+
sanitizedEvidence &&
184+
typeof sanitizedEvidence === 'object' &&
185+
Object.keys(sanitizedEvidence).length > 0
186+
);
187+
}, [sanitizedEvidence]);
188+
189+
if (!hasValidEvidence) {
190+
return (
191+
<div className="mt-2 rounded bg-muted p-2 text-xs text-muted-foreground">
192+
No evidence data available
193+
</div>
194+
);
195+
}
196+
197+
return (
198+
<div className="mt-2 rounded bg-muted p-2 text-xs relative group">
199+
<button
200+
onClick={handleDownload}
201+
className="absolute top-2 right-2 p-1.5 rounded-md bg-background/80 hover:bg-background border border-border/50 opacity-0 group-hover:opacity-100 transition-opacity"
202+
title="Download evidence as JSON"
203+
type="button"
204+
>
205+
<Download className="h-3 w-3 text-muted-foreground" />
206+
</button>
207+
<div className="overflow-auto">
208+
<JsonView
209+
value={sanitizedEvidence}
210+
collapsed={false}
211+
displayDataTypes={false}
212+
enableClipboard={false}
213+
style={{
214+
backgroundColor: 'transparent',
215+
fontFamily: 'ui-monospace, monospace',
216+
fontSize: '11px',
217+
}}
218+
/>
219+
</div>
220+
</div>
221+
);
222+
}
223+
224+

apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/TaskIntegrationChecks.tsx

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,13 @@ import {
2222
TrendingUp,
2323
XCircle,
2424
} from 'lucide-react';
25+
import { useActiveOrganization } from '@/utils/auth-client';
2526
import Image from 'next/image';
2627
import Link from 'next/link';
2728
import { useParams, useRouter, useSearchParams } from 'next/navigation';
2829
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2930
import { toast } from 'sonner';
31+
import { EvidenceJsonView } from './EvidenceJsonView';
3032

3133
interface TaskIntegrationCheck {
3234
integrationId: string;
@@ -89,6 +91,8 @@ export function TaskIntegrationChecks({ taskId, onTaskUpdated }: TaskIntegration
8991
const params = useParams();
9092
const searchParams = useSearchParams();
9193
const orgId = params.orgId as string;
94+
const activeOrg = useActiveOrganization();
95+
const organizationName = activeOrg.data?.name || orgId;
9296

9397
const [checks, setChecks] = useState<TaskIntegrationCheck[]>([]);
9498
const [storedRuns, setStoredRuns] = useState<StoredCheckRun[]>([]);
@@ -613,7 +617,11 @@ export function TaskIntegrationChecks({ taskId, onTaskUpdated }: TaskIntegration
613617
>
614618
<div className="overflow-hidden">
615619
<div className="px-4 pb-4 pt-2 border-t border-border/50 space-y-4">
616-
<GroupedCheckRuns runs={checkRuns} maxRuns={5} />
620+
<GroupedCheckRuns
621+
runs={checkRuns}
622+
maxRuns={5}
623+
organizationName={organizationName}
624+
/>
617625
</div>
618626
</div>
619627
</div>
@@ -694,7 +702,15 @@ export function TaskIntegrationChecks({ taskId, onTaskUpdated }: TaskIntegration
694702
}
695703

696704
// Group runs by date for display
697-
function GroupedCheckRuns({ runs, maxRuns = 5 }: { runs: StoredCheckRun[]; maxRuns?: number }) {
705+
function GroupedCheckRuns({
706+
runs,
707+
maxRuns = 5,
708+
organizationName,
709+
}: {
710+
runs: StoredCheckRun[];
711+
maxRuns?: number;
712+
organizationName: string;
713+
}) {
698714
const [showAll, setShowAll] = useState(false);
699715

700716
// Group runs by date
@@ -756,7 +772,14 @@ function GroupedCheckRuns({ runs, maxRuns = 5 }: { runs: StoredCheckRun[]; maxRu
756772
{dateRuns.map((run: StoredCheckRun) => {
757773
const isLatest = runIndex === 0;
758774
runIndex++;
759-
return <CheckRunItem key={run.id} run={run} isLatest={isLatest} />;
775+
return (
776+
<CheckRunItem
777+
key={run.id}
778+
run={run}
779+
isLatest={isLatest}
780+
organizationName={organizationName}
781+
/>
782+
);
760783
})}
761784
</div>
762785
</div>
@@ -775,7 +798,15 @@ function GroupedCheckRuns({ runs, maxRuns = 5 }: { runs: StoredCheckRun[]; maxRu
775798
}
776799

777800
// Individual check run item with expandable details
778-
function CheckRunItem({ run, isLatest }: { run: StoredCheckRun; isLatest: boolean }) {
801+
function CheckRunItem({
802+
run,
803+
isLatest,
804+
organizationName,
805+
}: {
806+
run: StoredCheckRun;
807+
isLatest: boolean;
808+
organizationName: string;
809+
}) {
779810
const [expanded, setExpanded] = useState(isLatest);
780811

781812
const timeAgo = formatDistanceToNow(new Date(run.createdAt), { addSuffix: true });
@@ -897,9 +928,11 @@ function CheckRunItem({ run, isLatest }: { run: StoredCheckRun; isLatest: boolea
897928
<summary className="text-muted-foreground cursor-pointer">
898929
View Evidence
899930
</summary>
900-
<pre className="mt-2 p-2 bg-muted rounded text-xs overflow-auto">
901-
{JSON.stringify(result.evidence, null, 2)}
902-
</pre>
931+
<EvidenceJsonView
932+
evidence={result.evidence}
933+
organizationName={organizationName}
934+
automationName={run.checkName}
935+
/>
903936
</details>
904937
)}
905938
</div>

bun.lock

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)