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:
parent
df0e82e372
commit
d14b6ebed3
@ -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/"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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}`)];
|
||||
|
||||
15
server/db.ts
15
server/db.ts
@ -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) ?? '' }
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -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>}
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user