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
12 changes: 6 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/api/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@diffity/api",
"version": "0.10.7",
"version": "0.10.8",
"private": true,
"type": "module",
"main": "./dist/index.js",
Expand Down
164 changes: 164 additions & 0 deletions packages/api/src/bundle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import {
COMMENT_KINDS,
COMMENT_SIDES,
THREAD_STATUSES,
type CommentAuthor,
type CommentKind,
type CommentSide,
type ThreadStatus,
} from './threads.js';
import { TOUR_STATUSES, type TourStatus } from './tours.js';
import type { GitHubRemote } from './github.js';
import {
FieldError,
author,
int,
lineRange,
list,
member,
optInt,
optStr,
parseWith,
record,
str,
timestamp,
type ParseResult,
} from './parse.js';

/**
* A prepared review in portable form: the threads and walkthrough tours of one session, pinned to
* the commit whose working tree their line numbers mean. Everything machine- and forge-local —
* ids, live state, what was submitted where — stays behind; an import mints its own.
*/
export interface ReviewBundle {
formatVersion: number;
/** The commit the anchors mean: importing onto any other HEAD would misplace every line. */
headSha: string;
/** The session ref the bundle was exported from, and the base it resolved to at export time. */
ref: string;
baseSha: string | null;
repo: GitHubRemote | null;
prNumber: number | null;
createdAt: string;
/** Who or what prepared this, free text — shown so a reader knows the findings' provenance. */
generator: string;
threads: BundleThread[];
tours: BundleTour[];
}

export interface BundleThread {
filePath: string;
side: CommentSide;
startLine: number;
endLine: number;
status: ThreadStatus;
anchorContent: string | null;
comments: BundleComment[];
}

export interface BundleComment {
author: CommentAuthor;
body: string;
kind: CommentKind;
createdAt: string;
}

export interface BundleTour {
topic: string;
body: string;
status: TourStatus;
steps: BundleTourStep[];
}

export interface BundleTourStep {
filePath: string;
startLine: number;
endLine: number;
body: string;
annotation: string;
}

export const BUNDLE_FORMAT_VERSION = 1;

export function parseReviewBundle(value: unknown): ParseResult<ReviewBundle> {
return parseWith(value, obj => {
const formatVersion = int(obj.formatVersion, 'formatVersion', 1);
if (formatVersion > BUNDLE_FORMAT_VERSION) {
throw new FieldError(
`formatVersion ${formatVersion} is newer than this diffity understands (${BUNDLE_FORMAT_VERSION})`,
);
}
return {
formatVersion,
headSha: str(obj.headSha, 'headSha'),
ref: str(obj.ref, 'ref'),
baseSha: obj.baseSha == null ? null : str(obj.baseSha, 'baseSha'),
repo: bundleRepo(obj.repo),
prNumber: optInt(obj.prNumber, 'prNumber', 1) ?? null,
createdAt: timestamp(obj.createdAt, 'createdAt'),
generator: str(obj.generator, 'generator'),
threads: list(obj.threads, 'threads').map((item, index) => bundleThread(item, `threads[${index}]`)),
tours: list(obj.tours, 'tours').map((item, index) => bundleTour(item, `tours[${index}]`)),
};
}, 'The bundle');
}

function bundleRepo(value: unknown): GitHubRemote | null {
if (value == null) {
return null;
}
const obj = record(value, 'repo');
return {
owner: str(obj.owner, 'repo.owner'),
repo: str(obj.repo, 'repo.repo'),
};
}

function bundleThread(value: unknown, label: string): BundleThread {
const obj = record(value, label);
const comments = list(obj.comments, `${label}.comments`)
.map((item, index) => bundleComment(item, `${label}.comments[${index}]`));
if (comments.length === 0) {
throw new FieldError(`${label}.comments must not be empty`);
}
return {
filePath: str(obj.filePath, `${label}.filePath`),
side: member(obj.side, `${label}.side`, COMMENT_SIDES),
// Line 0 is real: a general comment is about the whole diff and sits on no line.
...lineRange(obj, 0, label),
status: member(obj.status, `${label}.status`, THREAD_STATUSES),
anchorContent: optStr(obj.anchorContent, `${label}.anchorContent`) ?? null,
comments,
};
}

function bundleComment(value: unknown, label: string): BundleComment {
const obj = record(value, label);
return {
author: author(obj.author, `${label}.author`),
body: str(obj.body, `${label}.body`),
kind: member(obj.kind, `${label}.kind`, COMMENT_KINDS),
createdAt: timestamp(obj.createdAt, `${label}.createdAt`),
};
}

function bundleTour(value: unknown, label: string): BundleTour {
const obj = record(value, label);
return {
topic: str(obj.topic, `${label}.topic`),
body: optStr(obj.body, `${label}.body`) ?? '',
status: member(obj.status, `${label}.status`, TOUR_STATUSES),
steps: list(obj.steps, `${label}.steps`)
.map((item, index) => bundleTourStep(item, `${label}.steps[${index}]`)),
};
}

function bundleTourStep(value: unknown, label: string): BundleTourStep {
const obj = record(value, label);
return {
filePath: str(obj.filePath, `${label}.filePath`),
...lineRange(obj, 0, label),
body: optStr(obj.body, `${label}.body`) ?? '',
annotation: optStr(obj.annotation, `${label}.annotation`) ?? '',
};
}
1 change: 1 addition & 0 deletions packages/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ export * from './sessions.js';
export * from './diff.js';
export * from './tree.js';
export * from './requests.js';
export * from './bundle.js';
130 changes: 130 additions & 0 deletions packages/api/src/parse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { AUTHOR_TYPES, type CommentAuthor } from './threads.js';

/**
* What a parser answers: the typed value, or what is wrong with the input. Parsing happens at a
* boundary — a request body, a bundle file — so the message is written for whoever sent it.
*/
export type ParseResult<T> = { ok: true; value: T } | { ok: false; error: string };

// Hand-rolled rather than a schema library: the wire has one small shape per route, and a field
// reader that throws keeps each parser a flat object literal.

export class FieldError extends Error {}

export function record(value: unknown, label: string): Record<string, unknown> {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new FieldError(`${label} must be an object`);
}
return value as Record<string, unknown>;
}

export function parseWith<T>(
body: unknown,
build: (obj: Record<string, unknown>) => T,
rootLabel = 'Request body',
): ParseResult<T> {
try {
return { ok: true, value: build(record(body, rootLabel)) };
} catch (error) {
if (error instanceof FieldError) {
return { ok: false, error: error.message };
}
throw error;
}
}

export function str(value: unknown, label: string): string {
if (typeof value !== 'string' || value === '') {
throw new FieldError(`${label} must be a non-empty string`);
}
return value;
}

/** For the few fields where empty means something, like the editor path that means the repo root. */
export function anyStr(value: unknown, label: string): string {
if (typeof value !== 'string') {
throw new FieldError(`${label} must be a string`);
}
return value;
}

export function optStr(value: unknown, label: string): string | undefined {
return value == null ? undefined : anyStr(value, label);
}

export function int(value: unknown, label: string, min: number): number {
if (typeof value !== 'number' || !Number.isInteger(value) || value < min) {
throw new FieldError(`${label} must be an integer >= ${min}`);
}
return value;
}

export function optInt(value: unknown, label: string, min: number): number | undefined {
return value == null ? undefined : int(value, label, min);
}

export function optBool(value: unknown, label: string): boolean | undefined {
if (value == null) {
return undefined;
}
if (typeof value !== 'boolean') {
throw new FieldError(`${label} must be a boolean`);
}
return value;
}

export function member<T extends string>(value: unknown, label: string, values: readonly T[]): T {
if (typeof value !== 'string' || !(values as readonly string[]).includes(value)) {
throw new FieldError(`${label} must be one of: ${values.join(', ')}`);
}
return value as T;
}

export function optMember<T extends string>(
value: unknown,
label: string,
values: readonly T[],
): T | undefined {
return value == null ? undefined : member(value, label, values);
}

export function list(value: unknown, label: string): unknown[] {
if (!Array.isArray(value)) {
throw new FieldError(`${label} must be an array`);
}
return value;
}

/**
* Normalised to `toISOString()` form, so stored timestamps sort as time regardless of who wrote
* them. A zone is required: without one, `Date.parse` answers in the reader's zone, and the same
* bundle would import with different times on different machines.
*/
export function timestamp(value: unknown, label: string): string {
const raw = str(value, label);
const ms = Date.parse(raw);
if (Number.isNaN(ms) || !/(?:Z|[+-]\d{2}:?\d{2})$/i.test(raw)) {
throw new FieldError(`${label} must be an ISO 8601 timestamp with a time zone`);
}
return new Date(ms).toISOString();
}

export function lineRange(obj: Record<string, unknown>, min: number, label = ''): { startLine: number; endLine: number } {
const prefix = label ? `${label}.` : '';
const startLine = int(obj.startLine, `${prefix}startLine`, min);
const endLine = int(obj.endLine, `${prefix}endLine`, min);
if (endLine < startLine) {
throw new FieldError(`${prefix}endLine must not be before ${prefix}startLine`);
}
return { startLine, endLine };
}

/** Built from the fields it names, so nothing else in the input object reaches storage. */
export function author(value: unknown, label: string): CommentAuthor {
const obj = record(value, label);
return {
name: str(obj.name, `${label}.name`),
type: member(obj.type, `${label}.type`, AUTHOR_TYPES),
avatarUrl: optStr(obj.avatarUrl, `${label}.avatarUrl`),
};
}
Loading
Loading