Timeline: config-driven mute rules; show replies with thread-root context

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:44:55 +00:00
parent 9fedf8ecd3
commit df0e82e372
8 changed files with 84 additions and 10 deletions

View File

@ -3,6 +3,12 @@
"onlyInvolved": true,
"pollIntervalSeconds": 60,
"port": 4000,
"timeline": {
"mute": [
{ "actor": "qltysh" },
{ "actor": "github-actions", "kind": "review_comment" }
]
},
"repos": [
{
"name": "marketdojo/auction",

View File

@ -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<typeof muteRuleSchema>;
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<typeof configSchema>;

View File

@ -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<string, unknown>[];
.all(...params, ...this.muteParams, opts.limit + 1) as Record<string, unknown>[];
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<string, number> = {};
let total = 0;
for (const r of rows) {

View File

@ -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;

View File

@ -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);

View File

@ -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 {

View File

@ -148,6 +148,12 @@ export default function Timeline() {
<div className="tl-kind">
<KindLabel event={e} />
</div>
{e.inReplyTo && (
<div className="tl-reply-ctx">
<span className="actor">{e.inReplyTo.actor}</span>:{' '}
{e.inReplyTo.bodyExcerpt}
</div>
)}
{e.bodyExcerpt && <div className="body">{e.bodyExcerpt}</div>}
</div>
</div>

View File

@ -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;