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:
Joshua Coles 2026-07-28 14:21:25 +00:00
commit 1163357967
18 changed files with 2941 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
node_modules/
data/
dist/
dist-server/
web/dist/
*.log

2
.mise.toml Normal file
View File

@ -0,0 +1,2 @@
[tools]
node = "24"

37
config.json Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

94
server/categorize.ts Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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,
}));
}

View 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
View 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
View 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
View 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
View File

@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "dist-server",
"noEmit": false
},
"include": ["shared", "server"]
}