Diff hunks + thread context on review comments; preview env links; hide matrix placeholder

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:51:01 +00:00
parent df0e82e372
commit d14b6ebed3
11 changed files with 170 additions and 15 deletions

View File

@ -25,6 +25,11 @@
], ],
"specs": ["specs", "rspec", "rstest", "coverage", "qlty coverage", "qlty coverage diff"], "specs": ["specs", "rspec", "rstest", "coverage", "qlty coverage", "qlty coverage diff"],
"e2e": ["Preview E2E *"] "e2e": ["Preview E2E *"]
},
"ignoreChecks": ["Preview E2E ${{ matrix.folder }}"],
"preview": {
"label": "preview",
"urlTemplate": "https://pr-{number}.argocd.testmd.co.uk/"
} }
}, },
{ {

View File

@ -68,6 +68,7 @@ export function categorize(rules: RepoRules | undefined, contexts: CheckContext[
const buckets = new Map<CheckCategory, CheckInfo[]>(CATEGORIES.map((c) => [c, []])); const buckets = new Map<CheckCategory, CheckInfo[]>(CATEGORIES.map((c) => [c, []]));
for (const ctx of dedupeByName(contexts)) { for (const ctx of dedupeByName(contexts)) {
const name = ctx.__typename === 'StatusContext' ? ctx.context : ctx.name; const name = ctx.__typename === 'StatusContext' ? ctx.context : ctx.name;
if (rules?.ignore.has(name)) continue;
buckets.get(categoryFor(rules, name))!.push({ buckets.get(categoryFor(rules, name))!.push({
name, name,
state: checkState(ctx), state: checkState(ctx),

View File

@ -15,6 +15,14 @@ const repoSchema = z.object({
e2e: categorySchema.optional(), e2e: categorySchema.optional(),
}) })
.default({}), .default({}),
ignoreChecks: z.array(z.string()).default([]),
preview: z
.object({
label: z.string(),
/** `{number}` is replaced with the PR number. */
urlTemplate: z.string(),
})
.optional(),
}); });
const muteRuleSchema = z.object({ const muteRuleSchema = z.object({
@ -47,6 +55,8 @@ interface GlobMatcher {
export interface RepoRules { export interface RepoRules {
exact: Map<string, CheckCategory>; exact: Map<string, CheckCategory>;
globs: GlobMatcher[]; globs: GlobMatcher[];
ignore: Set<string>;
preview?: { label: string; urlTemplate: string };
} }
export interface AppConfig extends RawConfig { export interface AppConfig extends RawConfig {
@ -98,7 +108,12 @@ export function loadConfig(path = join(rootDir, 'config.json')): AppConfig {
} }
} }
} }
rulesByRepo.set(repo.name, { exact, globs }); rulesByRepo.set(repo.name, {
exact,
globs,
ignore: new Set(repo.ignoreChecks),
preview: repo.preview,
});
} }
const parts = ['is:pr', 'is:open', ...raw.repos.map((r) => `repo:${r.name}`)]; const parts = ['is:pr', 'is:open', ...raw.repos.map((r) => `repo:${r.name}`)];

View File

@ -55,6 +55,7 @@ export interface EventInsert {
reviewState: string | null; reviewState: string | null;
threadId: string | null; threadId: string | null;
path: string | null; path: string | null;
diffHunk: string | null;
read: boolean; read: boolean;
} }
@ -76,6 +77,11 @@ export class Db {
this.db.exec('PRAGMA foreign_keys = ON'); this.db.exec('PRAGMA foreign_keys = ON');
this.db.exec(SCHEMA); this.db.exec(SCHEMA);
const eventCols = this.db.prepare('PRAGMA table_info(events)').all() as { name: string }[];
if (!eventCols.some((c) => c.name === 'diff_hunk')) {
this.db.exec('ALTER TABLE events ADD COLUMN diff_hunk TEXT');
}
const clauses: string[] = []; const clauses: string[] = [];
for (const rule of opts.muteRules ?? []) { for (const rule of opts.muteRules ?? []) {
let clause = 'e.actor = ?'; let clause = 'e.actor = ?';
@ -155,10 +161,11 @@ export class Db {
this.db this.db
.prepare( .prepare(
`INSERT INTO events (id, pr_id, kind, actor, created_at, updated_at, body_excerpt, url, `INSERT INTO events (id, pr_id, kind, actor, created_at, updated_at, body_excerpt, url,
review_state, thread_id, path, read, read_at) review_state, thread_id, path, diff_hunk, read, read_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
body_excerpt = excluded.body_excerpt, updated_at = excluded.updated_at`, body_excerpt = excluded.body_excerpt, updated_at = excluded.updated_at,
diff_hunk = excluded.diff_hunk`,
) )
.run( .run(
e.id, e.id,
@ -172,6 +179,7 @@ export class Db {
e.reviewState, e.reviewState,
e.threadId, e.threadId,
e.path, e.path,
e.diffHunk,
e.read ? 1 : 0, e.read ? 1 : 0,
); );
} }
@ -229,6 +237,7 @@ 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,
diffHunk: (r.diff_hunk as string | null) ?? null,
inReplyTo: inReplyTo:
r.root_id && r.root_id !== r.id r.root_id && r.root_id !== r.id
? { actor: r.root_actor as string, bodyExcerpt: (r.root_body as string) ?? '' } ? { actor: r.root_actor as string, bodyExcerpt: (r.root_body as string) ?? '' }

View File

@ -16,6 +16,7 @@ export const LIST_QUERY = /* GraphQL */ `
headRefName headRefName
headRefOid headRefOid
repository { nameWithOwner } repository { nameWithOwner }
labels(first: 20) { nodes { name } }
commits(last: 1) { nodes { commit { statusCheckRollup { state } } } } commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }
reviewThreads(first: 50) { totalCount nodes { isResolved } } reviewThreads(first: 50) { totalCount nodes { isResolved } }
comments(first: 1) { totalCount } comments(first: 1) { totalCount }
@ -38,6 +39,7 @@ export interface ListPr {
headRefName: string; headRefName: string;
headRefOid: string; headRefOid: string;
repository: { nameWithOwner: string }; repository: { nameWithOwner: string };
labels: { nodes: { name: string }[] };
commits: { nodes: { commit: { statusCheckRollup: { state: string } | null } }[] }; commits: { nodes: { commit: { statusCheckRollup: { state: string } | null } }[] };
reviewThreads: { totalCount: number; nodes: { isResolved: boolean }[] }; reviewThreads: { totalCount: number; nodes: { isResolved: boolean }[] };
comments: { totalCount: number }; comments: { totalCount: number };
@ -90,7 +92,7 @@ export const DETAIL_QUERY = /* GraphQL */ `
isResolved isResolved
path path
comments(first: 30) { comments(first: 30) {
nodes { id author { login } bodyText createdAt updatedAt url } nodes { id author { login } bodyText createdAt updatedAt url diffHunk }
} }
} }
} }
@ -110,6 +112,8 @@ export interface DetailComment {
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
url: string; url: string;
/** Present only on review-thread comments. */
diffHunk?: string;
} }
export interface DetailPr { export interface DetailPr {

View File

@ -36,6 +36,7 @@ function signature(pr: ListPr): string {
pr.comments.totalCount, pr.comments.totalCount,
pr.reviews.totalCount, pr.reviews.totalCount,
pr.latestReviews.nodes.map((r) => `${r.author?.login}:${r.state}`), pr.latestReviews.nodes.map((r) => `${r.author?.login}:${r.state}`),
pr.labels.nodes.map((l) => l.name).sort(),
]), ]),
) )
.digest('hex'); .digest('hex');
@ -59,14 +60,20 @@ async function fetchList(config: AppConfig): Promise<ListPr[]> {
function buildSnapshot(config: AppConfig, pr: ListPr, detail: DetailPr | undefined, db: Db): PrSnapshot { function buildSnapshot(config: AppConfig, pr: ListPr, detail: DetailPr | undefined, db: Db): PrSnapshot {
const repo = pr.repository.nameWithOwner; const repo = pr.repository.nameWithOwner;
const rules = config.rulesByRepo.get(repo);
let categories; let categories;
if (detail) { if (detail) {
const contexts = detail.commits.nodes[0]?.commit.statusCheckRollup?.contexts.nodes ?? []; const contexts = detail.commits.nodes[0]?.commit.statusCheckRollup?.contexts.nodes ?? [];
categories = categorize(config.rulesByRepo.get(repo), contexts); categories = categorize(rules, contexts);
} else { } else {
// Detail node inaccessible this round — keep the previous check breakdown. // Detail node inaccessible this round — keep the previous check breakdown.
categories = db.getSnapshot(pr.id)?.categories ?? categorize(config.rulesByRepo.get(repo), []); categories = db.getSnapshot(pr.id)?.categories ?? categorize(rules, []);
} }
const labels = pr.labels.nodes.map((l) => l.name);
const previewUrl =
rules?.preview && labels.includes(rules.preview.label)
? rules.preview.urlTemplate.replaceAll('{number}', String(pr.number))
: null;
return { return {
id: pr.id, id: pr.id,
repo, repo,
@ -77,6 +84,8 @@ function buildSnapshot(config: AppConfig, pr: ListPr, detail: DetailPr | undefin
isDraft: pr.isDraft, isDraft: pr.isDraft,
branch: pr.headRefName, branch: pr.headRefName,
updatedAt: pr.updatedAt, updatedAt: pr.updatedAt,
labels,
previewUrl,
...deriveReviewState(pr), ...deriveReviewState(pr),
verdicts: deriveVerdicts(pr), verdicts: deriveVerdicts(pr),
categories, categories,

View File

@ -8,6 +8,17 @@ function excerpt(text: string): string {
return clean.length > EXCERPT_LEN ? `${clean.slice(0, EXCERPT_LEN - 1)}` : clean; return clean.length > EXCERPT_LEN ? `${clean.slice(0, EXCERPT_LEN - 1)}` : clean;
} }
const HUNK_LINES = 8;
const HUNK_CHARS = 1200;
/** GitHub's diffHunk ends at the commented line — keep the trailing context. */
function trimHunk(hunk: string | undefined): string | null {
if (!hunk) return null;
const lines = hunk.split('\n');
const out = lines.slice(-HUNK_LINES).join('\n');
return out.length > HUNK_CHARS ? out.slice(-HUNK_CHARS) : out;
}
export interface TimelineOpts { export interface TimelineOpts {
user: string; user: string;
/** First-ever sync: mark the historical backfill as already read. */ /** First-ever sync: mark the historical backfill as already read. */
@ -32,6 +43,7 @@ export function ingestTimeline(db: Db, pr: DetailPr, opts: TimelineOpts): number
reviewState: null, reviewState: null,
threadId: null, threadId: null,
path: null, path: null,
diffHunk: null,
read: false, read: false,
}); });
} }
@ -50,6 +62,7 @@ export function ingestTimeline(db: Db, pr: DetailPr, opts: TimelineOpts): number
reviewState: null, reviewState: null,
threadId: thread.id, threadId: thread.id,
path: thread.path, path: thread.path,
diffHunk: trimHunk(c.diffHunk),
read: false, read: false,
}); });
} }
@ -72,6 +85,7 @@ export function ingestTimeline(db: Db, pr: DetailPr, opts: TimelineOpts): number
reviewState: r.state, reviewState: r.state,
threadId: null, threadId: null,
path: null, path: null,
diffHunk: null,
read: false, read: false,
}); });
} }

View File

@ -36,6 +36,9 @@ export interface PrSnapshot {
isDraft: boolean; isDraft: boolean;
branch: string; branch: string;
updatedAt: string; updatedAt: string;
labels: string[];
/** Per-PR review environment, when the repo configures one and the label matches. */
previewUrl: string | null;
reviewState: ReviewState; reviewState: ReviewState;
unresolvedThreads: number; unresolvedThreads: number;
totalThreads: number; totalThreads: number;
@ -59,6 +62,8 @@ export interface TimelineEvent {
reviewState: string | null; reviewState: string | null;
path: string | null; path: string | null;
read: boolean; read: boolean;
/** Trailing lines of the diff hunk a review comment is anchored to. */
diffHunk: string | null;
/** Set when this is a reply within a review thread: the thread's root comment. */ /** Set when this is a reply within a review thread: the thread's root comment. */
inReplyTo: { actor: string; bodyExcerpt: string } | null; inReplyTo: { actor: string; bodyExcerpt: string } | null;
} }

View File

@ -20,6 +20,11 @@ export default function PrCard({ pr, unread }: { pr: PrSnapshot; unread: number
{pr.branch} {pr.branch}
</span> </span>
<span>{relativeTime(pr.updatedAt)}</span> <span>{relativeTime(pr.updatedAt)}</span>
{pr.previewUrl && (
<a className="preview-link" href={pr.previewUrl} target="_blank" rel="noreferrer">
preview
</a>
)}
</div> </div>
<div className="chips-row"> <div className="chips-row">
<ReviewBadge pr={pr} /> <ReviewBadge pr={pr} />

View File

@ -62,14 +62,43 @@ function KindLabel({ event }: { event: TimelineEvent }) {
if (event.kind === 'review_comment') { if (event.kind === 'review_comment') {
return ( return (
<span> <span>
commented{event.path ? ' on ' : ''} {event.inReplyTo ? 'replied' : 'commented'}
{event.path && <span className="path">{event.path.split('/').pop()}</span>} {event.path ? ' on ' : ''}
{event.path && (
<span className="path" title={event.path}>
{event.path.split('/').pop()}
</span>
)}
</span> </span>
); );
} }
return <span>commented</span>; return <span>commented</span>;
} }
function DiffHunk({ hunk }: { hunk: string }) {
return (
<pre className="tl-diff">
{hunk.split('\n').map((line, i) => (
<span
key={i}
className={
line.startsWith('+')
? 'diff-add'
: line.startsWith('-')
? 'diff-del'
: line.startsWith('@@')
? 'diff-meta'
: ''
}
>
{line || ' '}
{'\n'}
</span>
))}
</pre>
);
}
export default function Timeline() { export default function Timeline() {
const [unreadOnly, setUnreadOnly] = useState(false); const [unreadOnly, setUnreadOnly] = useState(false);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@ -144,14 +173,18 @@ export default function Timeline() {
readMutation.mutate({ id: e.id, read: !e.read }); readMutation.mutate({ id: e.id, read: !e.read });
}} }}
/> />
<div> <div className="tl-content">
<div className="tl-kind"> <div className="tl-kind">
<KindLabel event={e} /> <KindLabel event={e} />
</div> </div>
{e.diffHunk && !e.inReplyTo && <DiffHunk hunk={e.diffHunk} />}
{e.inReplyTo && ( {e.inReplyTo && (
<div className="tl-reply-ctx"> <div className="tl-thread">
<span className="actor">{e.inReplyTo.actor}</span>:{' '} {e.diffHunk && <DiffHunk hunk={e.diffHunk} />}
{e.inReplyTo.bodyExcerpt} <div className="tl-reply-ctx">
<span className="actor">{e.inReplyTo.actor}</span>{' '}
{e.inReplyTo.bodyExcerpt}
</div>
</div> </div>
)} )}
{e.bodyExcerpt && <div className="body">{e.bodyExcerpt}</div>} {e.bodyExcerpt && <div className="body">{e.bodyExcerpt}</div>}

View File

@ -288,6 +288,19 @@ button {
white-space: nowrap; white-space: nowrap;
} }
.preview-link {
color: var(--accent);
text-decoration: none;
font-weight: 600;
border: 1px solid var(--border);
border-radius: 4px;
padding: 0 5px;
}
.preview-link:hover {
border-color: var(--accent);
}
.unread-pill { .unread-pill {
background: var(--unread); background: var(--unread);
color: #fff; color: #fff;
@ -580,11 +593,20 @@ a.check-row:hover {
background: var(--unread); background: var(--unread);
} }
.tl-content {
min-width: 0;
flex: 1;
}
.tl-thread {
border-left: 2px solid var(--hairline);
padding-left: 8px;
margin: 3px 0;
}
.tl-reply-ctx { .tl-reply-ctx {
font-size: 12px; font-size: 12px;
color: var(--ink-muted); color: var(--ink-muted);
border-left: 2px solid var(--hairline);
padding-left: 7px;
margin: 2px 0; margin: 2px 0;
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
@ -597,6 +619,39 @@ a.check-row:hover {
font-weight: 600; font-weight: 600;
} }
.tl-diff {
margin: 3px 0;
padding: 6px 8px;
border: 1px solid var(--hairline);
border-radius: 6px;
background: var(--page);
font-family: ui-monospace, monospace;
font-size: 11px;
line-height: 1.5;
overflow-x: auto;
white-space: pre;
max-height: 160px;
overflow-y: auto;
}
.tl-diff .diff-add {
color: var(--status-pass-text);
background: var(--chip-pass-bg);
display: inline-block;
min-width: 100%;
}
.tl-diff .diff-del {
color: var(--status-fail-text);
background: var(--chip-fail-bg);
display: inline-block;
min-width: 100%;
}
.tl-diff .diff-meta {
color: var(--ink-muted);
}
.tl-item .body { .tl-item .body {
color: var(--ink-secondary); color: var(--ink-secondary);
font-size: 13px; font-size: 13px;