Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion boardflow/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"@types/react": "^19",
"@types/react-dom": "^19",
"openapi-typescript": "^7",
"typescript": "^6.0.3"
"typescript": "^6.0.3",
"vitest": "^4.1.6"
}
}
702 changes: 700 additions & 2 deletions boardflow/pnpm-lock.yaml

Large diffs are not rendered by default.

9 changes: 4 additions & 5 deletions boardflow/src/components/run-detail/run-detail-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { RunChecksSection } from '@/components/run-detail/run-checks-section';
import { RunDiffSummaryCard } from '@/components/run-detail/run-diff-summary-card';
import { RunHeader } from '@/components/run-detail/run-header';
import { Breadcrumb } from '@/components/ui/breadcrumb';
import { parseApiErrorMessage } from '@/lib/api/error';
import { $api } from '@/lib/api/react-query';
import type { Artifact, DiffResponse, ViewerEntry } from '@/lib/api/schema-types';
import { shortId } from '@/lib/format';
Expand Down Expand Up @@ -59,11 +60,9 @@ export function RunDetailContent({ repositoryId, boardProjectId, boardRunId }: P
const artifacts: Artifact[] = artifactsData?.items ?? [];
const viewers: Record<string, ViewerEntry> = viewerData?.viewers ?? {};
const diff: DiffResponse | null = diffData ?? null;
const diffErrorMessage =
diffError && (diffError as Record<string, unknown>)?.error
? ((diffError as Record<string, { message?: string }>).error?.message ??
'Failed to load diff data.')
: null;
const diffErrorMessage = diffError
? (parseApiErrorMessage(diffError) ?? 'Failed to load diff data.')
: null;

return (
<Box>
Expand Down
30 changes: 18 additions & 12 deletions boardflow/src/components/run-detail/run-diff-summary-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,18 +86,24 @@ export function RunDiffSummaryCard({
BOM changes: data format not recognized
</Text>
)}
{summary.checks != null && summary.checks.length > 0 && (
<HStack gap={4} fontSize='sm' flexWrap='wrap'>
<Text>Checks:</Text>
{summary.checks.map(([kind, check]) => (
<Text key={kind}>
{kind.toUpperCase()} {check.status_change} (
{check.error_delta >= 0 ? '+' : ''}
{check.error_delta}E, {check.warning_delta >= 0 ? '+' : ''}
{check.warning_delta}W)
</Text>
))}
</HStack>
{summary.checks === null ? (
<Text fontSize='sm' color='gray.500'>
Checks: data format not recognized
</Text>
) : (
summary.checks.length > 0 && (
<HStack gap={4} fontSize='sm' flexWrap='wrap'>
<Text>Checks:</Text>
{summary.checks.map(([kind, check]) => (
<Text key={kind}>
{kind.toUpperCase()} {check.status_change} (
{check.error_delta >= 0 ? '+' : ''}
{check.error_delta}E, {check.warning_delta >= 0 ? '+' : ''}
{check.warning_delta}W)
</Text>
))}
</HStack>
)
)}
{summary.artifactChanges ? (
<HStack gap={4} fontSize='sm'>
Expand Down
4 changes: 2 additions & 2 deletions boardflow/src/components/tokens/create-token-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { useForm } from '@tanstack/react-form';
import { useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { z } from 'zod';
import { parseApiErrorMessage } from '@/lib/api/error';
import { $api } from '@/lib/api/react-query';

const createTokenSchema = z.object({
Expand Down Expand Up @@ -61,8 +62,7 @@ export function CreateTokenDialog({ repositoryId, open, onOpenChange }: Props) {
queryKey: ['get', '/api/v1/repositories/{github_repository_id}/api-tokens'],
});
} catch (err: unknown) {
const apiErr = err as { error?: { message?: string } };
setServerError(apiErr.error?.message ?? 'トークンの作成に失敗しました');
setServerError(parseApiErrorMessage(err) ?? 'トークンの作成に失敗しました');
}
},
});
Expand Down
12 changes: 12 additions & 0 deletions boardflow/src/lib/api/error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/**
* Extract an error message from an unknown API error response.
* Returns `null` when the shape is not recognised, letting callers
* fall back to their own default message via `??`.
*/
export function parseApiErrorMessage(err: unknown): string | null {
if (typeof err !== 'object' || err === null) return null;
const obj = err as Record<string, unknown>;
if (typeof obj.error !== 'object' || obj.error === null) return null;
const inner = obj.error as Record<string, unknown>;
return typeof inner.message === 'string' ? inner.message : null;
}
11 changes: 0 additions & 11 deletions boardflow/src/lib/api/schema-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,3 @@ export type ViewerSourcesResponse = components['schemas']['ViewerSourcesResponse
export type ArtifactSummary = components['schemas']['ArtifactSummary'];
export type BoardRunDetail = components['schemas']['BoardRunDetailResponse'];
export type CheckInfo = components['schemas']['CheckInfo'];

/**
* Frontend-defined shape for diff summary (backend returns as unknown JSON).
* Fields are validated at runtime with type guards before access.
*/
export interface DiffSummary {
file_changes?: unknown;
bom_changes?: unknown;
checks?: unknown;
artifacts?: unknown;
}
148 changes: 148 additions & 0 deletions boardflow/src/lib/domain/__tests__/diff-summary.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { describe, expect, it } from 'vitest';
import { parseDiffSummary } from '../diff-summary';

describe('parseDiffSummary', () => {
// --- checks parsing ---
describe('checks', () => {
it('returns parsed entries when all entries are valid', () => {
const result = parseDiffSummary({
checks: {
erc: { status_change: 'pass→pass', error_delta: 0, warning_delta: -1 },
drc: { status_change: 'fail→pass', error_delta: -2, warning_delta: 0 },
},
});
expect(result.checks).toEqual([
['erc', { status_change: 'pass→pass', error_delta: 0, warning_delta: -1 }],
['drc', { status_change: 'fail→pass', error_delta: -2, warning_delta: 0 }],
]);
});

it('returns only valid entries when some entries are malformed', () => {
const result = parseDiffSummary({
checks: {
erc: { status_change: 'pass→pass', error_delta: 0, warning_delta: -1 },
drc: { bad: 'data' },
},
});
expect(result.checks).toEqual([
['erc', { status_change: 'pass→pass', error_delta: 0, warning_delta: -1 }],
]);
});

it('returns null when all entries are malformed (silent drop fix)', () => {
const result = parseDiffSummary({
checks: {
erc: { bad: 'data' },
drc: 'not an object',
},
});
expect(result.checks).toBeNull();
});

it('returns null when checks key is missing', () => {
const result = parseDiffSummary({});
expect(result.checks).toBeNull();
});

it('returns null when checks is not an object', () => {
const result = parseDiffSummary({ checks: 'string' });
expect(result.checks).toBeNull();
});

it('returns null when checks is an array', () => {
const result = parseDiffSummary({ checks: [1, 2, 3] });
expect(result.checks).toBeNull();
});

it('returns null when checks is null', () => {
const result = parseDiffSummary({ checks: null });
expect(result.checks).toBeNull();
});

it('returns empty array when checks is an empty object', () => {
const result = parseDiffSummary({ checks: {} });
expect(result.checks).toEqual([]);
});

it('uses result.data from safeParse (strips extra fields via schema)', () => {
const result = parseDiffSummary({
checks: {
erc: { status_change: 'pass', error_delta: 0, warning_delta: 0, extra_field: 'ignored' },
},
});
// zod strip mode removes extra fields
expect(result.checks).toEqual([
['erc', { status_change: 'pass', error_delta: 0, warning_delta: 0 }],
]);
});
});

// --- fileChanges parsing ---
describe('fileChanges', () => {
it('returns parsed data for valid input', () => {
const result = parseDiffSummary({
file_changes: { added: 1, removed: 2, changed: 3, unchanged: 4 },
});
expect(result.fileChanges).toEqual({ added: 1, removed: 2, changed: 3, unchanged: 4 });
});

it('returns null for malformed input', () => {
const result = parseDiffSummary({ file_changes: { added: 'not a number' } });
expect(result.fileChanges).toBeNull();
});
});

// --- bomChanges parsing ---
describe('bomChanges', () => {
it('returns parsed data for valid input', () => {
const result = parseDiffSummary({
bom_changes: { added: 1, removed: 0, changed: 2 },
});
expect(result.bomChanges).toEqual({ added: 1, removed: 0, changed: 2 });
});

it('returns null for malformed input', () => {
const result = parseDiffSummary({ bom_changes: 42 });
expect(result.bomChanges).toBeNull();
});
});

// --- artifactChanges parsing ---
describe('artifactChanges', () => {
it('returns parsed data for valid input', () => {
const result = parseDiffSummary({
artifacts: { added: 3, removed: 1, changed: 0 },
});
expect(result.artifactChanges).toEqual({ added: 3, removed: 1, changed: 0 });
});

it('returns null for malformed input', () => {
const result = parseDiffSummary({ artifacts: null });
expect(result.artifactChanges).toBeNull();
});
});

// --- edge cases ---
describe('edge cases', () => {
it('handles non-object raw input gracefully', () => {
const result = parseDiffSummary('not an object');
expect(result.fileChanges).toBeNull();
expect(result.bomChanges).toBeNull();
expect(result.checks).toBeNull();
expect(result.artifactChanges).toBeNull();
});

it('handles null raw input gracefully', () => {
const result = parseDiffSummary(null);
expect(result.fileChanges).toBeNull();
expect(result.bomChanges).toBeNull();
expect(result.checks).toBeNull();
expect(result.artifactChanges).toBeNull();
});

it('handles array raw input gracefully', () => {
const result = parseDiffSummary([1, 2, 3]);
expect(result.checks).toBeNull();
});
});
});
26 changes: 26 additions & 0 deletions boardflow/src/lib/domain/diff-summary-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { z } from 'zod';

export const FileChangesSchema = z.object({
added: z.number(),
removed: z.number(),
changed: z.number(),
unchanged: z.number(),
});

export const BomChangesSchema = z.object({
added: z.number(),
removed: z.number(),
changed: z.number(),
});

export const CheckEntrySchema = z.object({
status_change: z.string(),
error_delta: z.number(),
warning_delta: z.number(),
});

export const ArtifactChangesSchema = z.object({
added: z.number(),
removed: z.number(),
changed: z.number(),
});
43 changes: 33 additions & 10 deletions boardflow/src/lib/domain/diff-summary.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { isArtifactChanges, isBomChanges, isCheckEntry, isFileChanges, isRecord } from './guards';
import {
ArtifactChangesSchema,
BomChangesSchema,
CheckEntrySchema,
FileChangesSchema,
} from './diff-summary-schema';

export interface FileChanges {
added: number;
Expand Down Expand Up @@ -33,15 +38,33 @@ export interface ParsedDiffSummary {
}

export function parseDiffSummary(raw: unknown): ParsedDiffSummary {
const obj = isRecord(raw) ? raw : {};
const obj =
typeof raw === 'object' && raw !== null && !Array.isArray(raw)
? (raw as Record<string, unknown>)
: {};

const fileResult = FileChangesSchema.safeParse(obj.file_changes);
const bomResult = BomChangesSchema.safeParse(obj.bom_changes);
const artifactResult = ArtifactChangesSchema.safeParse(obj.artifacts);

let checks: [string, CheckEntry][] | null = null;
if (typeof obj.checks === 'object' && obj.checks !== null && !Array.isArray(obj.checks)) {
const rawEntries = Object.entries(obj.checks as Record<string, unknown>);
const parsed: [string, CheckEntry][] = [];
for (const [key, value] of rawEntries) {
const result = CheckEntrySchema.safeParse(value);
if (result.success) {
parsed.push([key, result.data]);
}
}
// If original had entries but none parsed, treat as unrecognized format (null)
checks = rawEntries.length > 0 && parsed.length === 0 ? null : parsed;
}

return {
fileChanges: isFileChanges(obj.file_changes) ? obj.file_changes : null,
bomChanges: isBomChanges(obj.bom_changes) ? obj.bom_changes : null,
checks: isRecord(obj.checks)
? Object.entries(obj.checks).filter((entry): entry is [string, CheckEntry] =>
isCheckEntry(entry[1]),
)
: null,
artifactChanges: isArtifactChanges(obj.artifacts) ? obj.artifacts : null,
fileChanges: fileResult.success ? fileResult.data : null,
bomChanges: bomResult.success ? bomResult.data : null,
checks,
artifactChanges: artifactResult.success ? artifactResult.data : null,
};
}
43 changes: 0 additions & 43 deletions boardflow/src/lib/domain/guards.ts
Original file line number Diff line number Diff line change
@@ -1,46 +1,3 @@
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

export function isFileChanges(
v: unknown,
): v is { added: number; removed: number; changed: number; unchanged: number } {
return (
isRecord(v) &&
typeof v.added === 'number' &&
typeof v.removed === 'number' &&
typeof v.changed === 'number' &&
typeof v.unchanged === 'number'
);
}

export function isBomChanges(v: unknown): v is { added: number; removed: number; changed: number } {
return (
isRecord(v) &&
typeof v.added === 'number' &&
typeof v.removed === 'number' &&
typeof v.changed === 'number'
);
}

export function isCheckEntry(
v: unknown,
): v is { status_change: string; error_delta: number; warning_delta: number } {
return (
isRecord(v) &&
typeof v.status_change === 'string' &&
typeof v.error_delta === 'number' &&
typeof v.warning_delta === 'number'
);
}

export function isArtifactChanges(
v: unknown,
): v is { added: number; removed: number; changed: number } {
return (
isRecord(v) &&
typeof v.added === 'number' &&
typeof v.removed === 'number' &&
typeof v.changed === 'number'
);
}
Loading
Loading