Server core: config, GitHub GraphQL polling, SQLite persistence, timeline ingestion
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DgBEW6qAwgn2fcQbA2f4ZQ
This commit is contained in:
commit
1163357967
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
data/
|
||||
dist/
|
||||
dist-server/
|
||||
web/dist/
|
||||
*.log
|
||||
2
.mise.toml
Normal file
2
.mise.toml
Normal file
@ -0,0 +1,2 @@
|
||||
[tools]
|
||||
node = "24"
|
||||
37
config.json
Normal file
37
config.json
Normal file
@ -0,0 +1,37 @@
|
||||
{
|
||||
"user": "joshuacoles",
|
||||
"onlyInvolved": true,
|
||||
"pollIntervalSeconds": 60,
|
||||
"port": 4000,
|
||||
"repos": [
|
||||
{
|
||||
"name": "marketdojo/auction",
|
||||
"checkCategories": {
|
||||
"lint": [
|
||||
"rubocop",
|
||||
"pronto/rubocop",
|
||||
"frontend_lint",
|
||||
"scss_modules",
|
||||
"translation_lint",
|
||||
"yamllint",
|
||||
"brakeman",
|
||||
"qlty check"
|
||||
],
|
||||
"specs": ["specs", "rspec", "rstest", "coverage", "qlty coverage", "qlty coverage diff"],
|
||||
"e2e": ["Preview E2E *"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "marketdojo/auction-build",
|
||||
"checkCategories": {
|
||||
"lint": ["Format", "qlty check", "yamllint"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "marketdojo/md_exts",
|
||||
"checkCategories": {
|
||||
"specs": ["tests"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
33
package.json
Normal file
33
package.json
Normal file
@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "pr-monitor",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch server/index.ts",
|
||||
"dev:web": "vite web",
|
||||
"sync:once": "tsx server/scripts/sync-once.ts",
|
||||
"build": "vite build web && tsc -p tsconfig.server.json",
|
||||
"start": "node dist-server/server/index.js",
|
||||
"typecheck": "tsc -p tsconfig.server.json --noEmit && tsc -p web/tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^5.1.0",
|
||||
"picomatch": "^4.0.2",
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tanstack/react-query": "^5.0.0",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/picomatch": "^4.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"tsx": "^4.0.0",
|
||||
"typescript": "^5.9.0",
|
||||
"vite": "^7.0.0"
|
||||
}
|
||||
}
|
||||
1807
pnpm-lock.yaml
generated
Normal file
1807
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
94
server/categorize.ts
Normal file
94
server/categorize.ts
Normal file
@ -0,0 +1,94 @@
|
||||
import type {
|
||||
CategoryState,
|
||||
CategoryStatus,
|
||||
CheckCategory,
|
||||
CheckInfo,
|
||||
} from '../shared/types.js';
|
||||
import type { RepoRules } from './config.js';
|
||||
import type { CheckContext } from './github/queries.js';
|
||||
|
||||
const FAIL_CONCLUSIONS = new Set([
|
||||
'FAILURE',
|
||||
'TIMED_OUT',
|
||||
'ACTION_REQUIRED',
|
||||
'CANCELLED',
|
||||
'STARTUP_FAILURE',
|
||||
]);
|
||||
const PENDING_STATUSES = new Set(['QUEUED', 'WAITING', 'PENDING', 'REQUESTED']);
|
||||
|
||||
export function checkState(ctx: CheckContext): CategoryState {
|
||||
if (ctx.__typename === 'StatusContext') {
|
||||
if (ctx.state === 'SUCCESS') return 'pass';
|
||||
if (ctx.state === 'ERROR' || ctx.state === 'FAILURE') return 'fail';
|
||||
return 'pending'; // PENDING, EXPECTED
|
||||
}
|
||||
if (ctx.status === 'IN_PROGRESS') return 'running';
|
||||
if (PENDING_STATUSES.has(ctx.status)) return 'pending';
|
||||
// COMPLETED
|
||||
if (ctx.conclusion === 'SKIPPED') return 'skipped';
|
||||
if (ctx.conclusion === 'SUCCESS' || ctx.conclusion === 'NEUTRAL') return 'pass';
|
||||
if (ctx.conclusion && FAIL_CONCLUSIONS.has(ctx.conclusion)) return 'fail';
|
||||
return 'pending'; // STALE or unknown
|
||||
}
|
||||
|
||||
export function categoryFor(rules: RepoRules | undefined, name: string): CheckCategory {
|
||||
if (!rules) return 'other';
|
||||
const exact = rules.exact.get(name);
|
||||
if (exact) return exact;
|
||||
for (const glob of rules.globs) {
|
||||
if (glob.isMatch(name)) return glob.category;
|
||||
}
|
||||
return 'other';
|
||||
}
|
||||
|
||||
const AGGREGATE_ORDER: CategoryState[] = ['fail', 'running', 'pending', 'pass', 'skipped'];
|
||||
const CATEGORIES: CheckCategory[] = ['lint', 'specs', 'e2e', 'other'];
|
||||
|
||||
/** A commit accrues one CheckRun per workflow run, so re-runs and multi-trigger
|
||||
* workflows duplicate names — keep only the latest run per name (like GitHub's UI). */
|
||||
function dedupeByName(contexts: CheckContext[]): CheckContext[] {
|
||||
const byName = new Map<string, CheckContext>();
|
||||
for (const ctx of contexts) {
|
||||
const name = ctx.__typename === 'StatusContext' ? ctx.context : ctx.name;
|
||||
const prev = byName.get(name);
|
||||
if (!prev) {
|
||||
byName.set(name, ctx);
|
||||
continue;
|
||||
}
|
||||
const prevAt = prev.__typename === 'CheckRun' ? (prev.startedAt ?? '') : '';
|
||||
const curAt = ctx.__typename === 'CheckRun' ? (ctx.startedAt ?? '') : '';
|
||||
if (curAt >= prevAt) byName.set(name, ctx);
|
||||
}
|
||||
return [...byName.values()];
|
||||
}
|
||||
|
||||
/** Group check contexts into lint/specs/e2e/other with aggregate state per category.
|
||||
* Every check is kept — skipped placeholders included — so nothing is ever hidden. */
|
||||
export function categorize(rules: RepoRules | undefined, contexts: CheckContext[]): CategoryStatus[] {
|
||||
const buckets = new Map<CheckCategory, CheckInfo[]>(CATEGORIES.map((c) => [c, []]));
|
||||
for (const ctx of dedupeByName(contexts)) {
|
||||
const name = ctx.__typename === 'StatusContext' ? ctx.context : ctx.name;
|
||||
buckets.get(categoryFor(rules, name))!.push({
|
||||
name,
|
||||
state: checkState(ctx),
|
||||
url: ctx.__typename === 'StatusContext' ? ctx.targetUrl : ctx.detailsUrl,
|
||||
});
|
||||
}
|
||||
|
||||
return CATEGORIES.map((category) => {
|
||||
const checks = buckets.get(category)!;
|
||||
checks.sort((a, b) => a.name.localeCompare(b.name));
|
||||
const active = checks.filter((c) => c.state !== 'skipped');
|
||||
let state: CategoryState = 'none';
|
||||
if (checks.length > 0) {
|
||||
state = AGGREGATE_ORDER.find((s) => checks.some((c) => c.state === s)) ?? 'none';
|
||||
}
|
||||
return {
|
||||
category,
|
||||
state,
|
||||
passed: checks.filter((c) => c.state === 'pass').length,
|
||||
total: active.length,
|
||||
checks,
|
||||
};
|
||||
});
|
||||
}
|
||||
85
server/config.ts
Normal file
85
server/config.ts
Normal file
@ -0,0 +1,85 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { z } from 'zod';
|
||||
import type { CheckCategory } from '../shared/types.js';
|
||||
|
||||
const categorySchema = z.array(z.string());
|
||||
|
||||
const repoSchema = z.object({
|
||||
name: z.string().regex(/^[\w.-]+\/[\w.-]+$/),
|
||||
checkCategories: z
|
||||
.object({
|
||||
lint: categorySchema.optional(),
|
||||
specs: categorySchema.optional(),
|
||||
e2e: categorySchema.optional(),
|
||||
})
|
||||
.default({}),
|
||||
});
|
||||
|
||||
const configSchema = z.object({
|
||||
user: z.string(),
|
||||
onlyInvolved: z.boolean().default(true),
|
||||
pollIntervalSeconds: z.number().int().min(15).default(60),
|
||||
port: z.number().int().default(4000),
|
||||
repos: z.array(repoSchema).min(1),
|
||||
});
|
||||
|
||||
export type RawConfig = z.infer<typeof configSchema>;
|
||||
|
||||
interface GlobMatcher {
|
||||
isMatch: (name: string) => boolean;
|
||||
category: CheckCategory;
|
||||
}
|
||||
|
||||
export interface RepoRules {
|
||||
exact: Map<string, CheckCategory>;
|
||||
globs: GlobMatcher[];
|
||||
}
|
||||
|
||||
export interface AppConfig extends RawConfig {
|
||||
rulesByRepo: Map<string, RepoRules>;
|
||||
searchQuery: string;
|
||||
}
|
||||
|
||||
const GLOB_CHARS = /[*?]/;
|
||||
|
||||
/** Check names are plain strings, not paths — `*` must match anything including `/`
|
||||
* (e.g. "Preview E2E *" → "Preview E2E e2e/regression/srm"), and `${{ }}` in names
|
||||
* must stay literal. So we compile a minimal glob ourselves instead of picomatch. */
|
||||
function compileNameGlob(pattern: string): (name: string) => boolean {
|
||||
const regex = new RegExp(
|
||||
`^${pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.')}$`,
|
||||
);
|
||||
return (name) => regex.test(name);
|
||||
}
|
||||
|
||||
export const rootDir = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
export function loadConfig(path = join(rootDir, 'config.json')): AppConfig {
|
||||
const raw = configSchema.parse(JSON.parse(readFileSync(path, 'utf8')));
|
||||
|
||||
const rulesByRepo = new Map<string, RepoRules>();
|
||||
for (const repo of raw.repos) {
|
||||
const exact = new Map<string, CheckCategory>();
|
||||
const globs: GlobMatcher[] = [];
|
||||
for (const [category, patterns] of Object.entries(repo.checkCategories) as [
|
||||
CheckCategory,
|
||||
string[],
|
||||
][]) {
|
||||
for (const pattern of patterns ?? []) {
|
||||
if (GLOB_CHARS.test(pattern)) {
|
||||
globs.push({ isMatch: compileNameGlob(pattern), category });
|
||||
} else {
|
||||
exact.set(pattern, category);
|
||||
}
|
||||
}
|
||||
}
|
||||
rulesByRepo.set(repo.name, { exact, globs });
|
||||
}
|
||||
|
||||
const parts = ['is:pr', 'is:open', ...raw.repos.map((r) => `repo:${r.name}`)];
|
||||
if (raw.onlyInvolved) parts.push(`involves:${raw.user}`);
|
||||
|
||||
return { ...raw, rulesByRepo, searchQuery: parts.join(' ') };
|
||||
}
|
||||
257
server/db.ts
Normal file
257
server/db.ts
Normal file
@ -0,0 +1,257 @@
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type { PrSnapshot, TimelineEvent, TimelineKind, TimelinePage } from '../shared/types.js';
|
||||
import { rootDir } from './config.js';
|
||||
|
||||
const SCHEMA = `
|
||||
CREATE TABLE IF NOT EXISTS meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS prs (
|
||||
id TEXT PRIMARY KEY,
|
||||
repo TEXT NOT NULL,
|
||||
number INTEGER NOT NULL,
|
||||
state TEXT NOT NULL DEFAULT 'OPEN',
|
||||
signature TEXT NOT NULL,
|
||||
snapshot TEXT NOT NULL,
|
||||
detail_synced_at TEXT,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE (repo, number)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id TEXT PRIMARY KEY,
|
||||
pr_id TEXT NOT NULL REFERENCES prs(id),
|
||||
kind TEXT NOT NULL,
|
||||
actor TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT,
|
||||
body_excerpt TEXT,
|
||||
url TEXT NOT NULL,
|
||||
review_state TEXT,
|
||||
thread_id TEXT,
|
||||
path TEXT,
|
||||
read INTEGER NOT NULL DEFAULT 0,
|
||||
read_at TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_events_created ON events (created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_pr ON events (pr_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_unread ON events (read) WHERE read = 0;
|
||||
`;
|
||||
|
||||
export interface EventInsert {
|
||||
id: string;
|
||||
prId: string;
|
||||
kind: TimelineKind;
|
||||
actor: string;
|
||||
createdAt: string;
|
||||
updatedAt: string | null;
|
||||
bodyExcerpt: string;
|
||||
url: string;
|
||||
reviewState: string | null;
|
||||
threadId: string | null;
|
||||
path: string | null;
|
||||
read: boolean;
|
||||
}
|
||||
|
||||
export class Db {
|
||||
private db: DatabaseSync;
|
||||
|
||||
constructor(path?: string) {
|
||||
if (!path) {
|
||||
mkdirSync(join(rootDir, 'data'), { recursive: true });
|
||||
path = join(rootDir, 'data', 'pr-monitor.sqlite');
|
||||
}
|
||||
this.db = new DatabaseSync(path);
|
||||
this.db.exec('PRAGMA journal_mode = WAL');
|
||||
this.db.exec('PRAGMA foreign_keys = ON');
|
||||
this.db.exec(SCHEMA);
|
||||
}
|
||||
|
||||
getMeta(key: string): string | null {
|
||||
const row = this.db.prepare('SELECT value FROM meta WHERE key = ?').get(key) as
|
||||
| { value: string | null }
|
||||
| undefined;
|
||||
return row?.value ?? null;
|
||||
}
|
||||
|
||||
setMeta(key: string, value: string | null): void {
|
||||
this.db
|
||||
.prepare(
|
||||
'INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value',
|
||||
)
|
||||
.run(key, value);
|
||||
}
|
||||
|
||||
getPrSignatures(): Map<string, { signature: string; detailSyncedAt: string | null }> {
|
||||
const rows = this.db
|
||||
.prepare("SELECT id, signature, detail_synced_at FROM prs WHERE state = 'OPEN'")
|
||||
.all() as { id: string; signature: string; detail_synced_at: string | null }[];
|
||||
return new Map(rows.map((r) => [r.id, { signature: r.signature, detailSyncedAt: r.detail_synced_at }]));
|
||||
}
|
||||
|
||||
upsertPr(snapshot: PrSnapshot, signature: string, detailSyncedAt: string | null): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO prs (id, repo, number, state, signature, snapshot, detail_synced_at, updated_at)
|
||||
VALUES (?, ?, ?, 'OPEN', ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
state = 'OPEN', signature = excluded.signature, snapshot = excluded.snapshot,
|
||||
detail_synced_at = COALESCE(excluded.detail_synced_at, prs.detail_synced_at),
|
||||
updated_at = excluded.updated_at`,
|
||||
)
|
||||
.run(
|
||||
snapshot.id,
|
||||
snapshot.repo,
|
||||
snapshot.number,
|
||||
signature,
|
||||
JSON.stringify(snapshot),
|
||||
detailSyncedAt,
|
||||
snapshot.updatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
/** Update signature/snapshot from list data only (no detail fetch happened). */
|
||||
markGone(ids: string[]): void {
|
||||
if (ids.length === 0) return;
|
||||
const stmt = this.db.prepare("UPDATE prs SET state = 'GONE' WHERE id = ?");
|
||||
for (const id of ids) stmt.run(id);
|
||||
}
|
||||
|
||||
getOpenSnapshots(): PrSnapshot[] {
|
||||
const rows = this.db
|
||||
.prepare("SELECT snapshot FROM prs WHERE state = 'OPEN' ORDER BY updated_at DESC")
|
||||
.all() as { snapshot: string }[];
|
||||
return rows.map((r) => JSON.parse(r.snapshot) as PrSnapshot);
|
||||
}
|
||||
|
||||
/** Insert or update an event. The conflict path never touches `read`. */
|
||||
upsertEvent(e: EventInsert): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO events (id, pr_id, kind, actor, created_at, updated_at, body_excerpt, url,
|
||||
review_state, thread_id, path, read, read_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
body_excerpt = excluded.body_excerpt, updated_at = excluded.updated_at`,
|
||||
)
|
||||
.run(
|
||||
e.id,
|
||||
e.prId,
|
||||
e.kind,
|
||||
e.actor,
|
||||
e.createdAt,
|
||||
e.updatedAt,
|
||||
e.bodyExcerpt,
|
||||
e.url,
|
||||
e.reviewState,
|
||||
e.threadId,
|
||||
e.path,
|
||||
e.read ? 1 : 0,
|
||||
);
|
||||
}
|
||||
|
||||
getSnapshot(id: string): PrSnapshot | null {
|
||||
const row = this.db.prepare('SELECT snapshot FROM prs WHERE id = ?').get(id) as
|
||||
| { snapshot: string }
|
||||
| undefined;
|
||||
return row ? (JSON.parse(row.snapshot) as PrSnapshot) : null;
|
||||
}
|
||||
|
||||
hasEvent(id: string): boolean {
|
||||
return this.db.prepare('SELECT 1 FROM events WHERE id = ?').get(id) !== undefined;
|
||||
}
|
||||
|
||||
timeline(opts: { limit: number; before?: string; unreadOnly?: boolean }): TimelinePage {
|
||||
const clauses = ["p.state != ''"];
|
||||
const params: (string | number)[] = [];
|
||||
if (opts.before) {
|
||||
clauses.push('(e.created_at < ? OR (e.created_at = ? AND e.id < ?))');
|
||||
const [createdAt, id] = splitCursor(opts.before);
|
||||
params.push(createdAt, createdAt, id);
|
||||
}
|
||||
if (opts.unreadOnly) clauses.push('e.read = 0');
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT e.*, p.repo, p.number AS pr_number, p.snapshot
|
||||
FROM events e JOIN prs p ON p.id = e.pr_id
|
||||
WHERE ${clauses.join(' AND ')}
|
||||
ORDER BY e.created_at DESC, e.id DESC
|
||||
LIMIT ?`,
|
||||
)
|
||||
.all(...params, opts.limit + 1) as Record<string, unknown>[];
|
||||
|
||||
const hasMore = rows.length > opts.limit;
|
||||
const page = rows.slice(0, opts.limit);
|
||||
const events: TimelineEvent[] = page.map((r) => ({
|
||||
id: r.id as string,
|
||||
prId: r.pr_id as string,
|
||||
repo: r.repo as string,
|
||||
prNumber: r.pr_number as number,
|
||||
prTitle: (JSON.parse(r.snapshot as string) as { title: string }).title,
|
||||
kind: r.kind as TimelineEvent['kind'],
|
||||
actor: r.actor as string,
|
||||
createdAt: r.created_at as string,
|
||||
bodyExcerpt: (r.body_excerpt as string) ?? '',
|
||||
url: r.url as string,
|
||||
reviewState: r.review_state as string | null,
|
||||
path: r.path as string | null,
|
||||
read: (r.read as number) === 1,
|
||||
}));
|
||||
const last = events[events.length - 1];
|
||||
return { events, nextCursor: hasMore && last ? joinCursor(last.createdAt, last.id) : null };
|
||||
}
|
||||
|
||||
markRead(id: string, read: boolean, at: string): void {
|
||||
this.db
|
||||
.prepare('UPDATE events SET read = ?, read_at = ? WHERE id = ?')
|
||||
.run(read ? 1 : 0, read ? at : null, id);
|
||||
}
|
||||
|
||||
markAllRead(at: string, before?: string): number {
|
||||
const result = before
|
||||
? this.db
|
||||
.prepare('UPDATE events SET read = 1, read_at = ? WHERE read = 0 AND created_at <= ?')
|
||||
.run(at, before)
|
||||
: this.db.prepare('UPDATE events SET read = 1, read_at = ? WHERE read = 0').run(at);
|
||||
return Number(result.changes);
|
||||
}
|
||||
|
||||
markAllReadForPr(prId: string, at: string): number {
|
||||
const result = this.db
|
||||
.prepare('UPDATE events SET read = 1, read_at = ? WHERE read = 0 AND pr_id = ?')
|
||||
.run(at, prId);
|
||||
return Number(result.changes);
|
||||
}
|
||||
|
||||
unreadCounts(): { byPr: Record<string, number>; total: number } {
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT e.pr_id, COUNT(*) AS n FROM events e
|
||||
JOIN prs p ON p.id = e.pr_id AND p.state = 'OPEN'
|
||||
WHERE e.read = 0 GROUP BY e.pr_id`,
|
||||
)
|
||||
.all() as { pr_id: string; n: number }[];
|
||||
const byPr: Record<string, number> = {};
|
||||
let total = 0;
|
||||
for (const r of rows) {
|
||||
byPr[r.pr_id] = r.n;
|
||||
total += r.n;
|
||||
}
|
||||
return { byPr, total };
|
||||
}
|
||||
}
|
||||
|
||||
export function joinCursor(createdAt: string, id: string): string {
|
||||
return `${createdAt}|${id}`;
|
||||
}
|
||||
|
||||
function splitCursor(cursor: string): [string, string] {
|
||||
const idx = cursor.indexOf('|');
|
||||
if (idx === -1) return [cursor, ''];
|
||||
return [cursor.slice(0, idx), cursor.slice(idx + 1)];
|
||||
}
|
||||
58
server/github/client.ts
Normal file
58
server/github/client.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import { getToken, invalidateToken } from './token.js';
|
||||
|
||||
export interface RateLimitInfo {
|
||||
cost: number;
|
||||
remaining: number;
|
||||
resetAt: string;
|
||||
}
|
||||
|
||||
export class GithubError extends Error {}
|
||||
|
||||
let lastRateLimit: RateLimitInfo | null = null;
|
||||
|
||||
export function getLastRateLimit(): RateLimitInfo | null {
|
||||
return lastRateLimit;
|
||||
}
|
||||
|
||||
export async function graphql<T>(
|
||||
query: string,
|
||||
variables: Record<string, unknown>,
|
||||
): Promise<T> {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
const res = await fetch('https://api.github.com/graphql', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
authorization: `bearer ${getToken()}`,
|
||||
'content-type': 'application/json',
|
||||
'user-agent': 'pr-monitor',
|
||||
},
|
||||
body: JSON.stringify({ query, variables }),
|
||||
});
|
||||
|
||||
if (res.status === 401 && attempt === 0) {
|
||||
invalidateToken();
|
||||
continue;
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new GithubError(`GitHub API ${res.status}: ${(await res.text()).slice(0, 300)}`);
|
||||
}
|
||||
|
||||
const body = (await res.json()) as {
|
||||
data?: T & { rateLimit?: RateLimitInfo };
|
||||
errors?: { message: string }[];
|
||||
};
|
||||
// Partial data with errors (e.g. one inaccessible node) is usable; only fail when
|
||||
// there is no data at all.
|
||||
if (!body.data) {
|
||||
const msg = body.errors?.map((e) => e.message).join('; ') ?? 'no data returned';
|
||||
throw new GithubError(`GraphQL error: ${msg.slice(0, 500)}`);
|
||||
}
|
||||
if (body.data.rateLimit) {
|
||||
lastRateLimit = body.data.rateLimit;
|
||||
console.log(
|
||||
`[github] cost=${lastRateLimit.cost} remaining=${lastRateLimit.remaining}`,
|
||||
);
|
||||
}
|
||||
return body.data;
|
||||
}
|
||||
}
|
||||
143
server/github/queries.ts
Normal file
143
server/github/queries.ts
Normal file
@ -0,0 +1,143 @@
|
||||
export const LIST_QUERY = /* GraphQL */ `
|
||||
query List($q: String!, $cursor: String) {
|
||||
rateLimit { cost remaining resetAt }
|
||||
search(query: $q, type: ISSUE, first: 50, after: $cursor) {
|
||||
issueCount
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes {
|
||||
... on PullRequest {
|
||||
id
|
||||
number
|
||||
title
|
||||
url
|
||||
isDraft
|
||||
updatedAt
|
||||
author { login }
|
||||
headRefName
|
||||
headRefOid
|
||||
repository { nameWithOwner }
|
||||
commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }
|
||||
reviewThreads(first: 50) { totalCount nodes { isResolved } }
|
||||
comments(first: 1) { totalCount }
|
||||
reviews(first: 1) { totalCount }
|
||||
latestReviews(first: 10) { nodes { author { login } state submittedAt url } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export interface ListPr {
|
||||
id: string;
|
||||
number: number;
|
||||
title: string;
|
||||
url: string;
|
||||
isDraft: boolean;
|
||||
updatedAt: string;
|
||||
author: { login: string } | null;
|
||||
headRefName: string;
|
||||
headRefOid: string;
|
||||
repository: { nameWithOwner: string };
|
||||
commits: { nodes: { commit: { statusCheckRollup: { state: string } | null } }[] };
|
||||
reviewThreads: { totalCount: number; nodes: { isResolved: boolean }[] };
|
||||
comments: { totalCount: number };
|
||||
reviews: { totalCount: number };
|
||||
latestReviews: {
|
||||
nodes: { author: { login: string } | null; state: string; submittedAt: string; url: string }[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface ListResult {
|
||||
search: {
|
||||
issueCount: number;
|
||||
pageInfo: { hasNextPage: boolean; endCursor: string | null };
|
||||
nodes: (ListPr | Record<string, never>)[];
|
||||
};
|
||||
}
|
||||
|
||||
export const DETAIL_QUERY = /* GraphQL */ `
|
||||
query Details($ids: [ID!]!) {
|
||||
rateLimit { cost remaining resetAt }
|
||||
nodes(ids: $ids) {
|
||||
... on PullRequest {
|
||||
id
|
||||
commits(last: 1) {
|
||||
nodes {
|
||||
commit {
|
||||
oid
|
||||
statusCheckRollup {
|
||||
state
|
||||
contexts(first: 100) {
|
||||
nodes {
|
||||
__typename
|
||||
... on CheckRun { name status conclusion detailsUrl startedAt }
|
||||
... on StatusContext { context state targetUrl }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
comments(last: 50) {
|
||||
nodes { id author { login } bodyText createdAt updatedAt url }
|
||||
}
|
||||
reviews(last: 30) {
|
||||
nodes { id author { login } state bodyText submittedAt url }
|
||||
}
|
||||
reviewThreads(first: 100) {
|
||||
nodes {
|
||||
id
|
||||
isResolved
|
||||
path
|
||||
comments(first: 30) {
|
||||
nodes { id author { login } bodyText createdAt updatedAt url }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export type CheckContext =
|
||||
| { __typename: 'CheckRun'; name: string; status: string; conclusion: string | null; detailsUrl: string | null; startedAt: string | null }
|
||||
| { __typename: 'StatusContext'; context: string; state: string; targetUrl: string | null };
|
||||
|
||||
export interface DetailComment {
|
||||
id: string;
|
||||
author: { login: string } | null;
|
||||
bodyText: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface DetailPr {
|
||||
id: string;
|
||||
commits: {
|
||||
nodes: {
|
||||
commit: {
|
||||
oid: string;
|
||||
statusCheckRollup: { state: string; contexts: { nodes: CheckContext[] } } | null;
|
||||
};
|
||||
}[];
|
||||
};
|
||||
comments: { nodes: DetailComment[] };
|
||||
reviews: {
|
||||
nodes: {
|
||||
id: string;
|
||||
author: { login: string } | null;
|
||||
state: string;
|
||||
bodyText: string;
|
||||
submittedAt: string | null;
|
||||
url: string;
|
||||
}[];
|
||||
};
|
||||
reviewThreads: {
|
||||
nodes: { id: string; isResolved: boolean; path: string | null; comments: { nodes: DetailComment[] } }[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface DetailResult {
|
||||
nodes: (DetailPr | null)[];
|
||||
}
|
||||
20
server/github/token.ts
Normal file
20
server/github/token.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
let cached: string | null = null;
|
||||
|
||||
export function getToken(): string {
|
||||
if (cached) return cached;
|
||||
const env = process.env.GITHUB_TOKEN;
|
||||
if (env) {
|
||||
cached = env;
|
||||
return cached;
|
||||
}
|
||||
cached = execFileSync('gh', ['auth', 'token'], { encoding: 'utf8' }).trim();
|
||||
if (!cached) throw new Error('gh auth token returned an empty token');
|
||||
return cached;
|
||||
}
|
||||
|
||||
/** Drop the cached token (called once on a 401 so the next call re-fetches). */
|
||||
export function invalidateToken(): void {
|
||||
cached = null;
|
||||
}
|
||||
133
server/poller.ts
Normal file
133
server/poller.ts
Normal file
@ -0,0 +1,133 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { PrSnapshot } from '../shared/types.js';
|
||||
import type { AppConfig } from './config.js';
|
||||
import type { Db } from './db.js';
|
||||
import { categorize } from './categorize.js';
|
||||
import { deriveReviewState, deriveVerdicts } from './reviewState.js';
|
||||
import { graphql } from './github/client.js';
|
||||
import {
|
||||
DETAIL_QUERY,
|
||||
LIST_QUERY,
|
||||
type DetailPr,
|
||||
type DetailResult,
|
||||
type ListPr,
|
||||
type ListResult,
|
||||
} from './github/queries.js';
|
||||
|
||||
const DETAIL_BATCH = 10;
|
||||
const ROLLING_REFRESH_MS = 15 * 60 * 1000;
|
||||
|
||||
export interface PollResult {
|
||||
changedPrIds: string[];
|
||||
newEventCount: number;
|
||||
prCount: number;
|
||||
}
|
||||
|
||||
function signature(pr: ListPr): string {
|
||||
const resolved = pr.reviewThreads.nodes.filter((t) => t.isResolved).length;
|
||||
return createHash('sha1')
|
||||
.update(
|
||||
JSON.stringify([
|
||||
pr.updatedAt,
|
||||
pr.headRefOid,
|
||||
pr.commits.nodes[0]?.commit.statusCheckRollup?.state ?? null,
|
||||
pr.reviewThreads.totalCount,
|
||||
resolved,
|
||||
pr.comments.totalCount,
|
||||
pr.reviews.totalCount,
|
||||
pr.latestReviews.nodes.map((r) => `${r.author?.login}:${r.state}`),
|
||||
]),
|
||||
)
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
async function fetchList(config: AppConfig): Promise<ListPr[]> {
|
||||
const prs: ListPr[] = [];
|
||||
let cursor: string | null = null;
|
||||
do {
|
||||
const data: ListResult = await graphql<ListResult>(LIST_QUERY, {
|
||||
q: config.searchQuery,
|
||||
cursor,
|
||||
});
|
||||
for (const node of data.search.nodes) {
|
||||
if ('id' in node) prs.push(node as ListPr);
|
||||
}
|
||||
cursor = data.search.pageInfo.hasNextPage ? data.search.pageInfo.endCursor : null;
|
||||
} while (cursor);
|
||||
return prs;
|
||||
}
|
||||
|
||||
function buildSnapshot(config: AppConfig, pr: ListPr, detail: DetailPr | undefined, db: Db): PrSnapshot {
|
||||
const repo = pr.repository.nameWithOwner;
|
||||
let categories;
|
||||
if (detail) {
|
||||
const contexts = detail.commits.nodes[0]?.commit.statusCheckRollup?.contexts.nodes ?? [];
|
||||
categories = categorize(config.rulesByRepo.get(repo), contexts);
|
||||
} else {
|
||||
// Detail node inaccessible this round — keep the previous check breakdown.
|
||||
categories = db.getSnapshot(pr.id)?.categories ?? categorize(config.rulesByRepo.get(repo), []);
|
||||
}
|
||||
return {
|
||||
id: pr.id,
|
||||
repo,
|
||||
number: pr.number,
|
||||
title: pr.title,
|
||||
url: pr.url,
|
||||
author: pr.author?.login ?? 'ghost',
|
||||
isDraft: pr.isDraft,
|
||||
branch: pr.headRefName,
|
||||
updatedAt: pr.updatedAt,
|
||||
...deriveReviewState(pr),
|
||||
verdicts: deriveVerdicts(pr),
|
||||
categories,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runPoll(config: AppConfig, db: Db): Promise<PollResult> {
|
||||
const now = new Date().toISOString();
|
||||
const listPrs = await fetchList(config);
|
||||
const known = db.getPrSignatures();
|
||||
|
||||
const changed: ListPr[] = [];
|
||||
for (const pr of listPrs) {
|
||||
const prev = known.get(pr.id);
|
||||
const stale =
|
||||
!prev?.detailSyncedAt || Date.now() - Date.parse(prev.detailSyncedAt) > ROLLING_REFRESH_MS;
|
||||
if (!prev || prev.signature !== signature(pr) || stale) changed.push(pr);
|
||||
}
|
||||
|
||||
const present = new Set(listPrs.map((p) => p.id));
|
||||
db.markGone([...known.keys()].filter((id) => !present.has(id)));
|
||||
|
||||
const detailById = new Map<string, DetailPr>();
|
||||
for (let i = 0; i < changed.length; i += DETAIL_BATCH) {
|
||||
const ids = changed.slice(i, i + DETAIL_BATCH).map((p) => p.id);
|
||||
const data = await graphql<DetailResult>(DETAIL_QUERY, { ids });
|
||||
for (const node of data.nodes) {
|
||||
if (node?.id) detailById.set(node.id, node);
|
||||
}
|
||||
}
|
||||
|
||||
const backfillAsRead =
|
||||
db.getMeta('first_sync_done') !== '1' && process.env.BACKFILL_UNREAD !== '1';
|
||||
let newEventCount = 0;
|
||||
for (const pr of changed) {
|
||||
const detail = detailById.get(pr.id);
|
||||
const snapshot = buildSnapshot(config, pr, detail, db);
|
||||
db.upsertPr(snapshot, signature(pr), detail ? now : null);
|
||||
if (detail) {
|
||||
newEventCount += ingest(db, detail, config.user, backfillAsRead);
|
||||
}
|
||||
}
|
||||
|
||||
db.setMeta('first_sync_done', '1');
|
||||
db.setMeta('last_sync_at', now);
|
||||
db.setMeta('last_sync_error', null);
|
||||
return { changedPrIds: changed.map((p) => p.id), newEventCount, prCount: listPrs.length };
|
||||
}
|
||||
|
||||
// Thin wrapper so poller stays the single import for index.ts / sync-once.
|
||||
import { ingestTimeline } from './timeline.js';
|
||||
function ingest(db: Db, detail: DetailPr, user: string, backfillAsRead: boolean): number {
|
||||
return ingestTimeline(db, detail, { user, backfillAsRead });
|
||||
}
|
||||
33
server/reviewState.ts
Normal file
33
server/reviewState.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import type { ReviewState, ReviewerVerdict } from '../shared/types.js';
|
||||
import type { ListPr } from './github/queries.js';
|
||||
|
||||
export function deriveReviewState(pr: ListPr): {
|
||||
reviewState: ReviewState;
|
||||
unresolvedThreads: number;
|
||||
totalThreads: number;
|
||||
} {
|
||||
const totalThreads = pr.reviewThreads.totalCount;
|
||||
const unresolvedThreads = pr.reviewThreads.nodes.filter((t) => !t.isResolved).length;
|
||||
let reviewState: ReviewState;
|
||||
if (pr.reviews.totalCount === 0 && totalThreads === 0) reviewState = 'no_review';
|
||||
else if (unresolvedThreads > 0) reviewState = 'outstanding';
|
||||
else reviewState = 'addressed';
|
||||
return { reviewState, unresolvedThreads, totalThreads };
|
||||
}
|
||||
|
||||
export function deriveVerdicts(pr: ListPr): ReviewerVerdict[] {
|
||||
const author = pr.author?.login;
|
||||
return pr.latestReviews.nodes
|
||||
.filter(
|
||||
(r): r is typeof r & { state: 'APPROVED' | 'CHANGES_REQUESTED' } =>
|
||||
(r.state === 'APPROVED' || r.state === 'CHANGES_REQUESTED') &&
|
||||
r.author?.login !== undefined &&
|
||||
r.author.login !== author,
|
||||
)
|
||||
.map((r) => ({
|
||||
reviewer: r.author!.login,
|
||||
state: r.state,
|
||||
url: r.url,
|
||||
submittedAt: r.submittedAt,
|
||||
}));
|
||||
}
|
||||
37
server/scripts/sync-once.ts
Normal file
37
server/scripts/sync-once.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { loadConfig } from '../config.js';
|
||||
import { Db } from '../db.js';
|
||||
import { runPoll } from '../poller.js';
|
||||
import { getLastRateLimit } from '../github/client.js';
|
||||
|
||||
const config = loadConfig();
|
||||
const db = new Db();
|
||||
|
||||
console.log(`search: ${config.searchQuery}\n`);
|
||||
const result = await runPoll(config, db);
|
||||
|
||||
const stateIcon: Record<string, string> = {
|
||||
fail: '✗',
|
||||
running: '◐',
|
||||
pending: '·',
|
||||
pass: '✓',
|
||||
skipped: '–',
|
||||
none: ' ',
|
||||
};
|
||||
|
||||
for (const pr of db.getOpenSnapshots()) {
|
||||
const cats = pr.categories
|
||||
.map((c) => `${c.category}:${stateIcon[c.state]}${c.total ? ` ${c.passed}/${c.total}` : ''}`)
|
||||
.join(' ');
|
||||
console.log(
|
||||
`${pr.repo}#${pr.number} [${pr.reviewState}${pr.unresolvedThreads ? ` ${pr.unresolvedThreads} open` : ''}] ${cats} ${pr.title.slice(0, 60)}`,
|
||||
);
|
||||
const other = pr.categories.find((c) => c.category === 'other');
|
||||
if (other && other.checks.length > 0) {
|
||||
console.log(` other checks: ${other.checks.map((c) => c.name).join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`\n${result.prCount} PRs, ${result.changedPrIds.length} changed, ${result.newEventCount} new events`,
|
||||
);
|
||||
console.log(`rate limit: ${JSON.stringify(getLastRateLimit())}`);
|
||||
91
server/timeline.ts
Normal file
91
server/timeline.ts
Normal file
@ -0,0 +1,91 @@
|
||||
import type { Db, EventInsert } from './db.js';
|
||||
import type { DetailPr } from './github/queries.js';
|
||||
|
||||
const EXCERPT_LEN = 280;
|
||||
|
||||
function excerpt(text: string): string {
|
||||
const clean = text.replace(/\s+/g, ' ').trim();
|
||||
return clean.length > EXCERPT_LEN ? `${clean.slice(0, EXCERPT_LEN - 1)}…` : clean;
|
||||
}
|
||||
|
||||
export interface TimelineOpts {
|
||||
user: string;
|
||||
/** First-ever sync: mark the historical backfill as already read. */
|
||||
backfillAsRead: boolean;
|
||||
}
|
||||
|
||||
/** Extract timeline events from a PR detail node and upsert them.
|
||||
* Returns the number of events that were new to the database. */
|
||||
export function ingestTimeline(db: Db, pr: DetailPr, opts: TimelineOpts): number {
|
||||
const events: EventInsert[] = [];
|
||||
|
||||
for (const c of pr.comments.nodes) {
|
||||
events.push({
|
||||
id: c.id,
|
||||
prId: pr.id,
|
||||
kind: 'issue_comment',
|
||||
actor: c.author?.login ?? 'ghost',
|
||||
createdAt: c.createdAt,
|
||||
updatedAt: c.updatedAt,
|
||||
bodyExcerpt: excerpt(c.bodyText),
|
||||
url: c.url,
|
||||
reviewState: null,
|
||||
threadId: null,
|
||||
path: null,
|
||||
read: false,
|
||||
});
|
||||
}
|
||||
|
||||
for (const thread of pr.reviewThreads.nodes) {
|
||||
for (const c of thread.comments.nodes) {
|
||||
events.push({
|
||||
id: c.id,
|
||||
prId: pr.id,
|
||||
kind: 'review_comment',
|
||||
actor: c.author?.login ?? 'ghost',
|
||||
createdAt: c.createdAt,
|
||||
updatedAt: c.updatedAt,
|
||||
bodyExcerpt: excerpt(c.bodyText),
|
||||
url: c.url,
|
||||
reviewState: null,
|
||||
threadId: thread.id,
|
||||
path: thread.path,
|
||||
read: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const r of pr.reviews.nodes) {
|
||||
if (r.state === 'PENDING') continue;
|
||||
// Empty COMMENTED reviews are wrappers around inline comments — skip to avoid
|
||||
// duplicating the thread items.
|
||||
if (r.state === 'COMMENTED' && r.bodyText.trim() === '') continue;
|
||||
events.push({
|
||||
id: r.id,
|
||||
prId: pr.id,
|
||||
kind: 'review',
|
||||
actor: r.author?.login ?? 'ghost',
|
||||
createdAt: r.submittedAt ?? '',
|
||||
updatedAt: null,
|
||||
bodyExcerpt: excerpt(r.bodyText),
|
||||
url: r.url,
|
||||
reviewState: r.state,
|
||||
threadId: null,
|
||||
path: null,
|
||||
read: false,
|
||||
});
|
||||
}
|
||||
|
||||
let newCount = 0;
|
||||
for (const e of events) {
|
||||
if (!e.createdAt) continue;
|
||||
if (db.hasEvent(e.id)) {
|
||||
db.upsertEvent(e); // refresh excerpt/updated_at; read untouched
|
||||
continue;
|
||||
}
|
||||
newCount++;
|
||||
const read = opts.backfillAsRead || e.actor === opts.user;
|
||||
db.upsertEvent({ ...e, read });
|
||||
}
|
||||
return newCount;
|
||||
}
|
||||
83
shared/types.ts
Normal file
83
shared/types.ts
Normal file
@ -0,0 +1,83 @@
|
||||
export type CheckCategory = 'lint' | 'specs' | 'e2e' | 'other';
|
||||
|
||||
export type CategoryState = 'fail' | 'running' | 'pending' | 'pass' | 'skipped' | 'none';
|
||||
|
||||
export interface CheckInfo {
|
||||
name: string;
|
||||
state: CategoryState;
|
||||
url: string | null;
|
||||
}
|
||||
|
||||
export interface CategoryStatus {
|
||||
category: CheckCategory;
|
||||
state: CategoryState;
|
||||
passed: number;
|
||||
/** Non-skipped check count (drives the "7/9" chip summary). */
|
||||
total: number;
|
||||
checks: CheckInfo[];
|
||||
}
|
||||
|
||||
export type ReviewState = 'no_review' | 'outstanding' | 'addressed';
|
||||
|
||||
export interface ReviewerVerdict {
|
||||
reviewer: string;
|
||||
state: 'APPROVED' | 'CHANGES_REQUESTED';
|
||||
url: string;
|
||||
submittedAt: string;
|
||||
}
|
||||
|
||||
export interface PrSnapshot {
|
||||
id: string;
|
||||
repo: string;
|
||||
number: number;
|
||||
title: string;
|
||||
url: string;
|
||||
author: string;
|
||||
isDraft: boolean;
|
||||
branch: string;
|
||||
updatedAt: string;
|
||||
reviewState: ReviewState;
|
||||
unresolvedThreads: number;
|
||||
totalThreads: number;
|
||||
verdicts: ReviewerVerdict[];
|
||||
categories: CategoryStatus[];
|
||||
}
|
||||
|
||||
export type TimelineKind = 'issue_comment' | 'review_comment' | 'review';
|
||||
|
||||
export interface TimelineEvent {
|
||||
id: string;
|
||||
prId: string;
|
||||
repo: string;
|
||||
prNumber: number;
|
||||
prTitle: string;
|
||||
kind: TimelineKind;
|
||||
actor: string;
|
||||
createdAt: string;
|
||||
bodyExcerpt: string;
|
||||
url: string;
|
||||
reviewState: string | null;
|
||||
path: string | null;
|
||||
read: boolean;
|
||||
}
|
||||
|
||||
export interface StatePayload {
|
||||
prs: PrSnapshot[];
|
||||
lastSyncAt: string | null;
|
||||
lastSyncError: string | null;
|
||||
unreadCountsByPr: Record<string, number>;
|
||||
unreadTotal: number;
|
||||
}
|
||||
|
||||
export interface TimelinePage {
|
||||
events: TimelineEvent[];
|
||||
nextCursor: string | null;
|
||||
}
|
||||
|
||||
export interface SyncEventPayload {
|
||||
lastSyncAt: string | null;
|
||||
error: string | null;
|
||||
changedPrIds: string[];
|
||||
newEventCount: number;
|
||||
unreadCount: number;
|
||||
}
|
||||
14
tsconfig.json
Normal file
14
tsconfig.json
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["shared", "server"]
|
||||
}
|
||||
8
tsconfig.server.json
Normal file
8
tsconfig.server.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist-server",
|
||||
"noEmit": false
|
||||
},
|
||||
"include": ["shared", "server"]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user