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:
parent
9fedf8ecd3
commit
df0e82e372
@ -3,6 +3,12 @@
|
||||
"onlyInvolved": true,
|
||||
"pollIntervalSeconds": 60,
|
||||
"port": 4000,
|
||||
"timeline": {
|
||||
"mute": [
|
||||
{ "actor": "qltysh" },
|
||||
{ "actor": "github-actions", "kind": "review_comment" }
|
||||
]
|
||||
},
|
||||
"repos": [
|
||||
{
|
||||
"name": "marketdojo/auction",
|
||||
|
||||
@ -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>;
|
||||
|
||||
48
server/db.ts
48
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<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) {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user