diff --git a/config.json b/config.json index 270a7f1..0898338 100644 --- a/config.json +++ b/config.json @@ -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/" } }, { diff --git a/server/categorize.ts b/server/categorize.ts index c1d97be..81f8efa 100644 --- a/server/categorize.ts +++ b/server/categorize.ts @@ -68,6 +68,7 @@ export function categorize(rules: RepoRules | undefined, contexts: CheckContext[ const buckets = new Map(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), diff --git a/server/config.ts b/server/config.ts index a251177..63a2f71 100644 --- a/server/config.ts +++ b/server/config.ts @@ -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; globs: GlobMatcher[]; + ignore: Set; + 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}`)]; diff --git a/server/db.ts b/server/db.ts index 9b5444f..6bb4c5a 100644 --- a/server/db.ts +++ b/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) ?? '' } diff --git a/server/github/queries.ts b/server/github/queries.ts index e4fe5aa..e4b7fa7 100644 --- a/server/github/queries.ts +++ b/server/github/queries.ts @@ -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 { diff --git a/server/poller.ts b/server/poller.ts index daadbe6..c2d62ee 100644 --- a/server/poller.ts +++ b/server/poller.ts @@ -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 { 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, diff --git a/server/timeline.ts b/server/timeline.ts index eacc086..1c079cd 100644 --- a/server/timeline.ts +++ b/server/timeline.ts @@ -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, }); } diff --git a/shared/types.ts b/shared/types.ts index 9260b43..490e8ed 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -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; } diff --git a/web/src/components/PrCard.tsx b/web/src/components/PrCard.tsx index 29147f8..45767e5 100644 --- a/web/src/components/PrCard.tsx +++ b/web/src/components/PrCard.tsx @@ -20,6 +20,11 @@ export default function PrCard({ pr, unread }: { pr: PrSnapshot; unread: number {pr.branch} {relativeTime(pr.updatedAt)} + {pr.previewUrl && ( + + preview ↗ + + )}
diff --git a/web/src/components/Timeline.tsx b/web/src/components/Timeline.tsx index c6bd270..bf19e96 100644 --- a/web/src/components/Timeline.tsx +++ b/web/src/components/Timeline.tsx @@ -62,14 +62,43 @@ function KindLabel({ event }: { event: TimelineEvent }) { if (event.kind === 'review_comment') { return ( - commented{event.path ? ' on ' : ''} - {event.path && {event.path.split('/').pop()}} + {event.inReplyTo ? 'replied' : 'commented'} + {event.path ? ' on ' : ''} + {event.path && ( + + {event.path.split('/').pop()} + + )} ); } return commented; } +function DiffHunk({ hunk }: { hunk: string }) { + return ( +
+      {hunk.split('\n').map((line, i) => (
+        
+          {line || ' '}
+          {'\n'}
+        
+      ))}
+    
+ ); +} + 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 }); }} /> -
+
+ {e.diffHunk && !e.inReplyTo && } {e.inReplyTo && ( -
- ↳ {e.inReplyTo.actor}:{' '} - {e.inReplyTo.bodyExcerpt} +
+ {e.diffHunk && } +
+ {e.inReplyTo.actor}{' '} + {e.inReplyTo.bodyExcerpt} +
)} {e.bodyExcerpt &&
{e.bodyExcerpt}
} diff --git a/web/src/styles/app.css b/web/src/styles/app.css index aebfa45..a46fbfd 100644 --- a/web/src/styles/app.css +++ b/web/src/styles/app.css @@ -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;