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
co-authored by Claude Fable 5
parent 9fedf8ecd3
commit df0e82e372
8 changed files with 84 additions and 10 deletions
+11
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>;
+40 -8
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) {
+1 -1
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;
+1 -1
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);