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"],
"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, []]));
for (const ctx of dedupeByName(contexts)) {
const name = ctx.__typename === 'StatusContext' ? ctx.context : ctx.name;
if (rules?.ignore.has(name)) continue;
buckets.get(categoryFor(rules, name))!.push({
name,
state: checkState(ctx),

View File

@ -15,6 +15,14 @@ const repoSchema = z.object({
e2e: categorySchema.optional(),
})
.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({
@ -47,6 +55,8 @@ interface GlobMatcher {
export interface RepoRules {
exact: Map<string, CheckCategory>;
globs: GlobMatcher[];
ignore: Set<string>;
preview?: { label: string; urlTemplate: string };
}
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}`)];

View File

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

View File

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

View File

@ -36,6 +36,7 @@ function signature(pr: ListPr): string {
pr.comments.totalCount,
pr.reviews.totalCount,
pr.latestReviews.nodes.map((r) => `${r.author?.login}:${r.state}`),
pr.labels.nodes.map((l) => l.name).sort(),
]),
)
.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 {
const repo = pr.repository.nameWithOwner;
const rules = config.rulesByRepo.get(repo);
let categories;
if (detail) {
const contexts = detail.commits.nodes[0]?.commit.statusCheckRollup?.contexts.nodes ?? [];
categories = categorize(config.rulesByRepo.get(repo), contexts);
categories = categorize(rules, contexts);
} else {
// 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 {
id: pr.id,
repo,
@ -77,6 +84,8 @@ function buildSnapshot(config: AppConfig, pr: ListPr, detail: DetailPr | undefin
isDraft: pr.isDraft,
branch: pr.headRefName,
updatedAt: pr.updatedAt,
labels,
previewUrl,
...deriveReviewState(pr),
verdicts: deriveVerdicts(pr),
categories,

View File

@ -8,6 +8,17 @@ function excerpt(text: string): string {
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 {
user: string;
/** 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,
threadId: null,
path: null,
diffHunk: null,
read: false,
});
}
@ -50,6 +62,7 @@ export function ingestTimeline(db: Db, pr: DetailPr, opts: TimelineOpts): number
reviewState: null,
threadId: thread.id,
path: thread.path,
diffHunk: trimHunk(c.diffHunk),
read: false,
});
}
@ -72,6 +85,7 @@ export function ingestTimeline(db: Db, pr: DetailPr, opts: TimelineOpts): number
reviewState: r.state,
threadId: null,
path: null,
diffHunk: null,
read: false,
});
}

View File

@ -36,6 +36,9 @@ export interface PrSnapshot {
isDraft: boolean;
branch: string;
updatedAt: string;
labels: string[];
/** Per-PR review environment, when the repo configures one and the label matches. */
previewUrl: string | null;
reviewState: ReviewState;
unresolvedThreads: number;
totalThreads: number;
@ -59,6 +62,8 @@ export interface TimelineEvent {
reviewState: string | null;
path: string | null;
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. */
inReplyTo: { actor: string; bodyExcerpt: string } | null;
}

View File

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

View File

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

View File

@ -288,6 +288,19 @@ button {
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 {
background: var(--unread);
color: #fff;
@ -580,11 +593,20 @@ a.check-row:hover {
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 {
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;
@ -597,6 +619,39 @@ a.check-row:hover {
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 {
color: var(--ink-secondary);
font-size: 13px;