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:
co-authored by
Claude Fable 5
parent
9fedf8ecd3
commit
df0e82e372
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user