From df0e82e372adf6d50fca68bdaed31ad8094922b0 Mon Sep 17 00:00:00 2001 From: Joshua Coles Date: Tue, 28 Jul 2026 14:44:55 +0000 Subject: [PATCH] Timeline: config-driven mute rules; show replies with thread-root context Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DgBEW6qAwgn2fcQbA2f4ZQ --- config.json | 6 +++++ server/config.ts | 11 ++++++++ server/db.ts | 48 +++++++++++++++++++++++++++------ server/index.ts | 2 +- server/scripts/sync-once.ts | 2 +- shared/types.ts | 2 ++ web/src/components/Timeline.tsx | 6 +++++ web/src/styles/app.css | 17 ++++++++++++ 8 files changed, 84 insertions(+), 10 deletions(-) diff --git a/config.json b/config.json index b868f99..270a7f1 100644 --- a/config.json +++ b/config.json @@ -3,6 +3,12 @@ "onlyInvolved": true, "pollIntervalSeconds": 60, "port": 4000, + "timeline": { + "mute": [ + { "actor": "qltysh" }, + { "actor": "github-actions", "kind": "review_comment" } + ] + }, "repos": [ { "name": "marketdojo/auction", diff --git a/server/config.ts b/server/config.ts index b2f1971..a251177 100644 --- a/server/config.ts +++ b/server/config.ts @@ -17,6 +17,14 @@ const repoSchema = z.object({ .default({}), }); +const muteRuleSchema = z.object({ + actor: z.string(), + kind: z.enum(['issue_comment', 'review_comment', 'review']).optional(), + bodyContains: z.string().optional(), +}); + +export type MuteRule = z.infer; + const configSchema = z.object({ user: z.string(), onlyInvolved: z.boolean().default(true), @@ -24,6 +32,9 @@ const configSchema = z.object({ port: z.number().int().default(4000), host: z.string().default('0.0.0.0'), repos: z.array(repoSchema).min(1), + timeline: z + .object({ mute: z.array(muteRuleSchema).default([]) }) + .default({ mute: [] }), }); export type RawConfig = z.infer; diff --git a/server/db.ts b/server/db.ts index ab42d78..9b5444f 100644 --- a/server/db.ts +++ b/server/db.ts @@ -2,7 +2,7 @@ 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'; +import { rootDir, type MuteRule } from './config.js'; const SCHEMA = ` CREATE TABLE IF NOT EXISTS meta ( @@ -60,8 +60,13 @@ export interface EventInsert { export class Db { private db: DatabaseSync; + /** Mute rules compiled to a SQL fragment; applied when *reading* the timeline and + * unread counts, so config edits retroactively hide/unhide without data loss. */ + private muteSql = ''; + private muteParams: string[] = []; - constructor(path?: string) { + constructor(opts: { path?: string; muteRules?: MuteRule[] } = {}) { + let path = opts.path; if (!path) { mkdirSync(join(rootDir, 'data'), { recursive: true }); path = join(rootDir, 'data', 'pr-monitor.sqlite'); @@ -70,6 +75,22 @@ export class Db { this.db.exec('PRAGMA journal_mode = WAL'); this.db.exec('PRAGMA foreign_keys = ON'); this.db.exec(SCHEMA); + + const clauses: string[] = []; + for (const rule of opts.muteRules ?? []) { + let clause = 'e.actor = ?'; + this.muteParams.push(rule.actor); + if (rule.kind) { + clause += ' AND e.kind = ?'; + this.muteParams.push(rule.kind); + } + if (rule.bodyContains) { + clause += ' AND e.body_excerpt LIKE ?'; + this.muteParams.push(`%${rule.bodyContains}%`); + } + clauses.push(`(${clause})`); + } + if (clauses.length > 0) this.muteSql = ` AND NOT (${clauses.join(' OR ')})`; } getMeta(key: string): string | null { @@ -177,13 +198,20 @@ export class Db { 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 ')} + `SELECT e.*, p.repo, p.number AS pr_number, p.snapshot, + r.root_id, r.root_actor, r.root_body + FROM events e + JOIN prs p ON p.id = e.pr_id + LEFT JOIN ( + SELECT thread_id, id AS root_id, actor AS root_actor, + body_excerpt AS root_body, MIN(created_at) + FROM events WHERE thread_id IS NOT NULL GROUP BY thread_id + ) r ON r.thread_id = e.thread_id + WHERE ${clauses.join(' AND ')}${this.muteSql} ORDER BY e.created_at DESC, e.id DESC LIMIT ?`, ) - .all(...params, opts.limit + 1) as Record[]; + .all(...params, ...this.muteParams, opts.limit + 1) as Record[]; const hasMore = rows.length > opts.limit; const page = rows.slice(0, opts.limit); @@ -201,6 +229,10 @@ export class Db { reviewState: r.review_state as string | null, path: r.path as string | null, read: (r.read as number) === 1, + inReplyTo: + r.root_id && r.root_id !== r.id + ? { actor: r.root_actor as string, bodyExcerpt: (r.root_body as string) ?? '' } + : null, })); const last = events[events.length - 1]; return { events, nextCursor: hasMore && last ? joinCursor(last.createdAt, last.id) : null }; @@ -233,9 +265,9 @@ export class 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`, + WHERE e.read = 0${this.muteSql} GROUP BY e.pr_id`, ) - .all() as { pr_id: string; n: number }[]; + .all(...this.muteParams) as { pr_id: string; n: number }[]; const byPr: Record = {}; let total = 0; for (const r of rows) { diff --git a/server/index.ts b/server/index.ts index 331d47a..7ee7cfb 100644 --- a/server/index.ts +++ b/server/index.ts @@ -6,7 +6,7 @@ import { createApp, buildSyncPayload } from './routes.js'; import { broadcast } from './sse.js'; const config = loadConfig(); -const db = new Db(); +const db = new Db({ muteRules: config.timeline.mute }); let polling = false; diff --git a/server/scripts/sync-once.ts b/server/scripts/sync-once.ts index 929c660..cdb141e 100644 --- a/server/scripts/sync-once.ts +++ b/server/scripts/sync-once.ts @@ -4,7 +4,7 @@ import { runPoll } from '../poller.js'; import { getLastRateLimit } from '../github/client.js'; const config = loadConfig(); -const db = new Db(); +const db = new Db({ muteRules: config.timeline.mute }); console.log(`search: ${config.searchQuery}\n`); const result = await runPoll(config, db); diff --git a/shared/types.ts b/shared/types.ts index ebe4f52..9260b43 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -59,6 +59,8 @@ export interface TimelineEvent { reviewState: string | null; path: string | null; read: boolean; + /** Set when this is a reply within a review thread: the thread's root comment. */ + inReplyTo: { actor: string; bodyExcerpt: string } | null; } export interface StatePayload { diff --git a/web/src/components/Timeline.tsx b/web/src/components/Timeline.tsx index 9554411..c6bd270 100644 --- a/web/src/components/Timeline.tsx +++ b/web/src/components/Timeline.tsx @@ -148,6 +148,12 @@ export default function Timeline() {
+ {e.inReplyTo && ( +
+ ↳ {e.inReplyTo.actor}:{' '} + {e.inReplyTo.bodyExcerpt} +
+ )} {e.bodyExcerpt &&
{e.bodyExcerpt}
} diff --git a/web/src/styles/app.css b/web/src/styles/app.css index 5c3a22a..aebfa45 100644 --- a/web/src/styles/app.css +++ b/web/src/styles/app.css @@ -580,6 +580,23 @@ a.check-row:hover { background: var(--unread); } +.tl-reply-ctx { + font-size: 12px; + color: var(--ink-muted); + border-left: 2px solid var(--hairline); + padding-left: 7px; + margin: 2px 0; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + overflow-wrap: anywhere; +} + +.tl-reply-ctx .actor { + font-weight: 600; +} + .tl-item .body { color: var(--ink-secondary); font-size: 13px;