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,
|
"onlyInvolved": true,
|
||||||
"pollIntervalSeconds": 60,
|
"pollIntervalSeconds": 60,
|
||||||
"port": 4000,
|
"port": 4000,
|
||||||
|
"timeline": {
|
||||||
|
"mute": [
|
||||||
|
{ "actor": "qltysh" },
|
||||||
|
{ "actor": "github-actions", "kind": "review_comment" }
|
||||||
|
]
|
||||||
|
},
|
||||||
"repos": [
|
"repos": [
|
||||||
{
|
{
|
||||||
"name": "marketdojo/auction",
|
"name": "marketdojo/auction",
|
||||||
|
|||||||
@ -17,6 +17,14 @@ const repoSchema = z.object({
|
|||||||
.default({}),
|
.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({
|
const configSchema = z.object({
|
||||||
user: z.string(),
|
user: z.string(),
|
||||||
onlyInvolved: z.boolean().default(true),
|
onlyInvolved: z.boolean().default(true),
|
||||||
@ -24,6 +32,9 @@ const configSchema = z.object({
|
|||||||
port: z.number().int().default(4000),
|
port: z.number().int().default(4000),
|
||||||
host: z.string().default('0.0.0.0'),
|
host: z.string().default('0.0.0.0'),
|
||||||
repos: z.array(repoSchema).min(1),
|
repos: z.array(repoSchema).min(1),
|
||||||
|
timeline: z
|
||||||
|
.object({ mute: z.array(muteRuleSchema).default([]) })
|
||||||
|
.default({ mute: [] }),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type RawConfig = z.infer<typeof configSchema>;
|
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 { mkdirSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import type { PrSnapshot, TimelineEvent, TimelineKind, TimelinePage } from '../shared/types.js';
|
import type { PrSnapshot, TimelineEvent, TimelineKind, TimelinePage } from '../shared/types.js';
|
||||||
import { rootDir } from './config.js';
|
import { rootDir, type MuteRule } from './config.js';
|
||||||
|
|
||||||
const SCHEMA = `
|
const SCHEMA = `
|
||||||
CREATE TABLE IF NOT EXISTS meta (
|
CREATE TABLE IF NOT EXISTS meta (
|
||||||
@ -60,8 +60,13 @@ export interface EventInsert {
|
|||||||
|
|
||||||
export class Db {
|
export class Db {
|
||||||
private db: DatabaseSync;
|
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) {
|
if (!path) {
|
||||||
mkdirSync(join(rootDir, 'data'), { recursive: true });
|
mkdirSync(join(rootDir, 'data'), { recursive: true });
|
||||||
path = join(rootDir, 'data', 'pr-monitor.sqlite');
|
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 journal_mode = WAL');
|
||||||
this.db.exec('PRAGMA foreign_keys = ON');
|
this.db.exec('PRAGMA foreign_keys = ON');
|
||||||
this.db.exec(SCHEMA);
|
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 {
|
getMeta(key: string): string | null {
|
||||||
@ -177,13 +198,20 @@ export class Db {
|
|||||||
if (opts.unreadOnly) clauses.push('e.read = 0');
|
if (opts.unreadOnly) clauses.push('e.read = 0');
|
||||||
const rows = this.db
|
const rows = this.db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT e.*, p.repo, p.number AS pr_number, p.snapshot
|
`SELECT e.*, p.repo, p.number AS pr_number, p.snapshot,
|
||||||
FROM events e JOIN prs p ON p.id = e.pr_id
|
r.root_id, r.root_actor, r.root_body
|
||||||
WHERE ${clauses.join(' AND ')}
|
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
|
ORDER BY e.created_at DESC, e.id DESC
|
||||||
LIMIT ?`,
|
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 hasMore = rows.length > opts.limit;
|
||||||
const page = rows.slice(0, opts.limit);
|
const page = rows.slice(0, opts.limit);
|
||||||
@ -201,6 +229,10 @@ export class Db {
|
|||||||
reviewState: r.review_state as string | null,
|
reviewState: r.review_state as string | null,
|
||||||
path: r.path as string | null,
|
path: r.path as string | null,
|
||||||
read: (r.read as number) === 1,
|
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];
|
const last = events[events.length - 1];
|
||||||
return { events, nextCursor: hasMore && last ? joinCursor(last.createdAt, last.id) : null };
|
return { events, nextCursor: hasMore && last ? joinCursor(last.createdAt, last.id) : null };
|
||||||
@ -233,9 +265,9 @@ export class Db {
|
|||||||
.prepare(
|
.prepare(
|
||||||
`SELECT e.pr_id, COUNT(*) AS n FROM events e
|
`SELECT e.pr_id, COUNT(*) AS n FROM events e
|
||||||
JOIN prs p ON p.id = e.pr_id AND p.state = 'OPEN'
|
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> = {};
|
const byPr: Record<string, number> = {};
|
||||||
let total = 0;
|
let total = 0;
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { createApp, buildSyncPayload } from './routes.js';
|
|||||||
import { broadcast } from './sse.js';
|
import { broadcast } from './sse.js';
|
||||||
|
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const db = new Db();
|
const db = new Db({ muteRules: config.timeline.mute });
|
||||||
|
|
||||||
let polling = false;
|
let polling = false;
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { runPoll } from '../poller.js';
|
|||||||
import { getLastRateLimit } from '../github/client.js';
|
import { getLastRateLimit } from '../github/client.js';
|
||||||
|
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const db = new Db();
|
const db = new Db({ muteRules: config.timeline.mute });
|
||||||
|
|
||||||
console.log(`search: ${config.searchQuery}\n`);
|
console.log(`search: ${config.searchQuery}\n`);
|
||||||
const result = await runPoll(config, db);
|
const result = await runPoll(config, db);
|
||||||
|
|||||||
@ -59,6 +59,8 @@ export interface TimelineEvent {
|
|||||||
reviewState: string | null;
|
reviewState: string | null;
|
||||||
path: string | null;
|
path: string | null;
|
||||||
read: boolean;
|
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 {
|
export interface StatePayload {
|
||||||
|
|||||||
@ -148,6 +148,12 @@ export default function Timeline() {
|
|||||||
<div className="tl-kind">
|
<div className="tl-kind">
|
||||||
<KindLabel event={e} />
|
<KindLabel event={e} />
|
||||||
</div>
|
</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>}
|
{e.bodyExcerpt && <div className="body">{e.bodyExcerpt}</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -580,6 +580,23 @@ a.check-row:hover {
|
|||||||
background: var(--unread);
|
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 {
|
.tl-item .body {
|
||||||
color: var(--ink-secondary);
|
color: var(--ink-secondary);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user