From d177ee2b7b2eba4ad4e4795f49033b0f1648dd67 Mon Sep 17 00:00:00 2001 From: Alan Daniel Date: Mon, 13 Apr 2026 20:49:55 -0400 Subject: [PATCH 1/2] Group timeline events and show conflicting files in merge card - Group consecutive label add/remove events by same actor into single timeline entries - Group consecutive review request events the same way - Detect and display conflicting file paths in PR merge status card - Conflicts section uses collapsible pattern matching checks section --- .../details/grouped-label-event.tsx | 216 ++++++++++++++++++ .../issues/detail/issue-detail-activity.tsx | 122 ++++++++-- .../pulls/detail/pull-detail-activity.tsx | 204 ++++++++++++++--- apps/dashboard/src/lib/github.functions.ts | 37 ++- apps/dashboard/src/lib/github.types.ts | 15 ++ 5 files changed, 542 insertions(+), 52 deletions(-) create mode 100644 apps/dashboard/src/components/details/grouped-label-event.tsx diff --git a/apps/dashboard/src/components/details/grouped-label-event.tsx b/apps/dashboard/src/components/details/grouped-label-event.tsx new file mode 100644 index 0000000..3c4f249 --- /dev/null +++ b/apps/dashboard/src/components/details/grouped-label-event.tsx @@ -0,0 +1,216 @@ +import { LabelPill } from "#/components/details/label-pill"; +import type { + GitHubActor, + GroupedLabelEvent, + GroupedReviewRequestEvent, + TimelineEvent, +} from "#/lib/github.types"; + +const GROUP_THRESHOLD_MS = 60_000; + +type GroupedItem = + | T + | { type: "label_group"; date: string; data: GroupedLabelEvent } + | { + type: "review_request_group"; + date: string; + data: GroupedReviewRequestEvent; + }; + +/** + * Groups consecutive label and review-request events by the same actor + * that occur within a short time window into single grouped items. + */ +export function groupTimelineEvents< + T extends { type: string; date: string; data: unknown }, +>(items: T[]): GroupedItem[] { + const result: GroupedItem[] = []; + + let i = 0; + while (i < items.length) { + const item = items[i]; + + if (item.type !== "event") { + result.push(item); + i++; + continue; + } + + const event = item.data as TimelineEvent; + + const isLabel = event.event === "labeled" || event.event === "unlabeled"; + const isReviewRequest = + event.event === "review_requested" || + event.event === "review_request_removed"; + + if (!isLabel && !isReviewRequest) { + result.push(item); + i++; + continue; + } + + // Collect consecutive events of the same kind by the same actor + const actor = event.actor; + const eventKind = isLabel ? "label" : "review_request"; + const events: TimelineEvent[] = [event]; + + let j = i + 1; + while (j < items.length) { + const next = items[j]; + if (next.type !== "event") break; + + const nextEvent = next.data as TimelineEvent; + const nextIsLabel = + nextEvent.event === "labeled" || nextEvent.event === "unlabeled"; + const nextIsReviewRequest = + nextEvent.event === "review_requested" || + nextEvent.event === "review_request_removed"; + const nextKind = nextIsLabel + ? "label" + : nextIsReviewRequest + ? "review_request" + : null; + + if (nextKind !== eventKind) break; + if (nextEvent.actor?.login !== actor?.login) break; + + const timeDiff = Math.abs( + new Date(nextEvent.createdAt).getTime() - + new Date(event.createdAt).getTime(), + ); + if (timeDiff > GROUP_THRESHOLD_MS) break; + + events.push(nextEvent); + j++; + } + + if (events.length === 1) { + result.push(item); + i++; + continue; + } + + if (eventKind === "label") { + const added: { name: string; color: string }[] = []; + const removed: { name: string; color: string }[] = []; + for (const e of events) { + if (!e.label) continue; + if (e.event === "labeled") added.push(e.label); + else removed.push(e.label); + } + result.push({ + type: "label_group" as const, + date: item.date, + data: { actor, added, removed, createdAt: item.date }, + }); + } else { + const requested: (GitHubActor | { login: string })[] = []; + const removed: (GitHubActor | { login: string })[] = []; + for (const e of events) { + const reviewer = + e.requestedReviewer ?? + (e.requestedTeam ? { login: e.requestedTeam.name } : null); + if (!reviewer) continue; + if (e.event === "review_requested") requested.push(reviewer); + else removed.push(reviewer); + } + result.push({ + type: "review_request_group" as const, + date: item.date, + data: { actor, requested, removed, createdAt: item.date }, + }); + } + + i = j; + } + + return result; +} + +export function GroupedLabelDescription({ + group, +}: { + group: GroupedLabelEvent; +}) { + return ( + + + {group.added.length > 0 && ( + <> + {" added "} + {group.added.map((label) => ( + + ))} + + )} + {group.added.length > 0 && group.removed.length > 0 && " and"} + {group.removed.length > 0 && ( + <> + {" removed "} + {group.removed.map((label) => ( + + ))} + + )} + {" labels"} + + ); +} + +export function GroupedReviewRequestDescription({ + group, +}: { + group: GroupedReviewRequestEvent; +}) { + return ( + + + {group.requested.length > 0 && ( + <> + {" requested review from "} + {group.requested.map((reviewer, i) => ( + + {i > 0 && ", "} + + + ))} + + )} + {group.requested.length > 0 && group.removed.length > 0 && " and"} + {group.removed.length > 0 && ( + <> + {" removed review request for "} + {group.removed.map((reviewer, i) => ( + + {i > 0 && ", "} + + + ))} + + )} + + ); +} + +function ActorMention({ + actor, +}: { + actor: GitHubActor | { login: string } | null | undefined; +}) { + const login = actor?.login ?? "someone"; + return ( + + {login} + + ); +} diff --git a/apps/dashboard/src/components/issues/detail/issue-detail-activity.tsx b/apps/dashboard/src/components/issues/detail/issue-detail-activity.tsx index 9fcd7c5..aa4d9ee 100644 --- a/apps/dashboard/src/components/issues/detail/issue-detail-activity.tsx +++ b/apps/dashboard/src/components/issues/detail/issue-detail-activity.tsx @@ -15,6 +15,11 @@ import { DetailActivityHeader, DetailCommentBox, } from "#/components/details/detail-activity"; +import { + GroupedLabelDescription, + GroupedReviewRequestDescription, + groupTimelineEvents, +} from "#/components/details/grouped-label-event"; import { LabelPill } from "#/components/details/label-pill"; import { formatRelativeTime } from "#/lib/format-relative-time"; import { getCommentPage, getTimelineEventPage } from "#/lib/github.functions"; @@ -23,6 +28,8 @@ import type { CommentPagination, EventPagination, GitHubActor, + GroupedLabelEvent, + GroupedReviewRequestEvent, IssueComment, IssuePageData, TimelineEvent, @@ -34,7 +41,13 @@ const LOAD_MORE_CHUNK = 20; type IssueTimelineItem = | { type: "comment"; date: string; data: IssueComment } - | { type: "event"; date: string; data: TimelineEvent }; + | { type: "event"; date: string; data: TimelineEvent } + | { type: "label_group"; date: string; data: GroupedLabelEvent } + | { + type: "review_request_group"; + date: string; + data: GroupedReviewRequestEvent; + }; function useWindowedTimeline( items: T[], @@ -251,18 +264,20 @@ export function IssueDetailActivitySection({ scope: GitHubQueryScope; issueAuthor: GitHubActor | null; }) { - const allItems: IssueTimelineItem[] = [ - ...(comments ?? []).map((comment) => ({ - type: "comment" as const, - date: comment.createdAt, - data: comment, - })), - ...(events ?? []).map((event) => ({ - type: "event" as const, - date: event.createdAt, - data: event, - })), - ].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); + const allItems = groupTimelineEvents( + [ + ...(comments ?? []).map((comment) => ({ + type: "comment" as const, + date: comment.createdAt, + data: comment, + })), + ...(events ?? []).map((event) => ({ + type: "event" as const, + date: event.createdAt, + data: event, + })), + ].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()), + ) as IssueTimelineItem[]; const totalCount = (comments?.length ?? 0) + (events?.length ?? 0); @@ -328,10 +343,18 @@ export function IssueDetailActivitySection({ index < visibleItems.length - 1 ? visibleItems[index + 1].type : null; - const isConsecutiveEvent = - item.type === "event" && previousType === "event"; - const isLastInEventRun = - item.type === "event" && nextType !== "event"; + const eventLikeTypes = new Set([ + "event", + "label_group", + "review_request_group", + ]); + const isEventLike = eventLikeTypes.has(item.type); + const prevIsEventLike = + previousType !== null && eventLikeTypes.has(previousType); + const nextIsEventLike = + nextType !== null && eventLikeTypes.has(nextType); + const isConsecutiveEvent = isEventLike && prevIsEventLike; + const isLastInEventRun = isEventLike && !nextIsEventLike; const row = (() => { if (item.type === "comment") { @@ -368,6 +391,71 @@ export function IssueDetailActivitySection({ ); } + if ( + item.type === "label_group" || + item.type === "review_request_group" + ) { + const group = item.data; + const hasActorAvatar = group.actor?.avatarUrl; + const icon = + item.type === "label_group" ? ( + + ) : ( + + ); + return ( +
+ {hasActorAvatar ? ( + {group.actor?.login} + ) : ( +
+ {icon} +
+ )} + + {item.type === "label_group" ? ( + + ) : ( + + )} + + + {formatRelativeTime(group.createdAt)} + +
+ ); + } + const event = item.data; const icon = getIssueEventIcon(event); const description = getIssueEventDescription(event); diff --git a/apps/dashboard/src/components/pulls/detail/pull-detail-activity.tsx b/apps/dashboard/src/components/pulls/detail/pull-detail-activity.tsx index df6fe9b..89766d6 100644 --- a/apps/dashboard/src/components/pulls/detail/pull-detail-activity.tsx +++ b/apps/dashboard/src/components/pulls/detail/pull-detail-activity.tsx @@ -39,6 +39,11 @@ import { DetailActivityHeader, DetailCommentBox, } from "#/components/details/detail-activity"; +import { + GroupedLabelDescription, + GroupedReviewRequestDescription, + groupTimelineEvents, +} from "#/components/details/grouped-label-event"; import { LabelPill } from "#/components/details/label-pill"; import { formatRelativeTime } from "#/lib/format-relative-time"; import { @@ -58,6 +63,8 @@ import type { CommentPagination, EventPagination, GitHubActor, + GroupedLabelEvent, + GroupedReviewRequestEvent, PullCheckRun, PullComment, PullCommit, @@ -355,6 +362,7 @@ function MergeStatusCard({ reviews, mergeable, mergeableState, + conflictingFiles = [], behindBy, baseRefName, canUpdateBranch, @@ -398,11 +406,7 @@ function MergeStatusCard({ {/* Conflicts / branch status */} {hasConflicts ? ( - } - title="This branch has conflicts that must be resolved" - description="Use the command line to resolve conflicts." - /> + ) : isBehind ? ( } @@ -756,6 +760,70 @@ function ChecksSection({ ); } +// ── Conflicts section ────────────────────────────────────────────── + +function ConflictsSection({ + conflictingFiles, +}: { + conflictingFiles: string[]; +}) { + const [open, setOpen] = useState(conflictingFiles.length > 0); + const hasFiles = conflictingFiles.length > 0; + + if (!hasFiles) { + return ( + } + title="This branch has conflicts that must be resolved" + description="Use the command line to resolve conflicts." + /> + ); + } + + return ( + + + + + +
+ {conflictingFiles.map((file) => ( +
+ {file} +
+ ))} +
+
+
+ ); +} + // ── Branch status section ─────────────────────────────────────────── function UpdateBranchButton({ @@ -1129,6 +1197,12 @@ type TimelineItem = | { type: "comment"; date: string; data: PullComment } | { type: "commit"; date: string; data: PullCommit } | { type: "event"; date: string; data: TimelineEvent } + | { type: "label_group"; date: string; data: GroupedLabelEvent } + | { + type: "review_request_group"; + date: string; + data: GroupedReviewRequestEvent; + } | { type: "merged"; date: string; data: PullDetail }; const WINDOW_THRESHOLD = 25; @@ -1349,28 +1423,30 @@ function ActivityTimeline({ repo: string; pullNumber: number; }) { - const allItems: TimelineItem[] = [ - ...comments.map((comment) => ({ - type: "comment" as const, - date: comment.createdAt, - data: comment, - })), - ...commits.map((commit) => ({ - type: "commit" as const, - date: commit.createdAt, - data: commit, - })), - ...events - .filter((event) => !(event.event === "closed" && pr.isMerged)) - .map((event) => ({ - type: "event" as const, - date: event.createdAt, - data: event, + const allItems = groupTimelineEvents( + [ + ...comments.map((comment) => ({ + type: "comment" as const, + date: comment.createdAt, + data: comment, + })), + ...commits.map((commit) => ({ + type: "commit" as const, + date: commit.createdAt, + data: commit, })), - ...(pr.isMerged && pr.mergedAt - ? [{ type: "merged" as const, date: pr.mergedAt, data: pr }] - : []), - ].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); + ...events + .filter((event) => !(event.event === "closed" && pr.isMerged)) + .map((event) => ({ + type: "event" as const, + date: event.createdAt, + data: event, + })), + ...(pr.isMerged && pr.mergedAt + ? [{ type: "merged" as const, date: pr.mergedAt, data: pr }] + : []), + ].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()), + ) as TimelineItem[]; const { visibleItems, @@ -1400,9 +1476,18 @@ function ActivityTimeline({ item.type === "commit" && previousType === "commit"; const isLastInCommitRun = item.type === "commit" && nextType !== "commit"; - const isConsecutiveEvent = - item.type === "event" && previousType === "event"; - const isLastInEventRun = item.type === "event" && nextType !== "event"; + const eventLikeTypes = new Set([ + "event", + "label_group", + "review_request_group", + ]); + const isEventLike = eventLikeTypes.has(item.type); + const prevIsEventLike = + previousType !== null && eventLikeTypes.has(previousType); + const nextIsEventLike = + nextType !== null && eventLikeTypes.has(nextType); + const isConsecutiveEvent = isEventLike && prevIsEventLike; + const isLastInEventRun = isEventLike && !nextIsEventLike; const row = (() => { if (item.type === "comment") { @@ -1524,6 +1609,67 @@ function ActivityTimeline({ ); } + if ( + item.type === "label_group" || + item.type === "review_request_group" + ) { + const group = item.data; + const hasActorAvatar = group.actor?.avatarUrl; + const icon = + item.type === "label_group" ? ( + + ) : ( + + ); + return ( +
+ {hasActorAvatar ? ( + {group.actor?.login} + ) : ( +
+ {icon} +
+ )} + + {item.type === "label_group" ? ( + + ) : ( + + )} + + + {formatRelativeTime(group.createdAt)} + +
+ ); + } + const event = item.data; return ( null) + : null, + ]); behindBy = comparison.data.ahead_by; + + if (hasConflicts && prFiles && comparison.data.files) { + const baseChangedFiles = new Set( + comparison.data.files.map((f) => f.filename), + ); + conflictingFiles = prFiles.data + .map((f) => f.filename) + .filter((f) => baseChangedFiles.has(f)); + } } catch { behindBy = null; } @@ -3186,6 +3210,7 @@ async function computePullStatus( mergeable: pull.mergeable, mergeableState: typeof pull.mergeable_state === "string" ? pull.mergeable_state : null, + conflictingFiles, behindBy, baseRefName: pull.base.ref, canUpdateBranch, diff --git a/apps/dashboard/src/lib/github.types.ts b/apps/dashboard/src/lib/github.types.ts index c5a821f..c2b50df 100644 --- a/apps/dashboard/src/lib/github.types.ts +++ b/apps/dashboard/src/lib/github.types.ts @@ -167,6 +167,20 @@ export type TimelineEvent = { body?: string; }; +export type GroupedLabelEvent = { + actor: GitHubActor | null; + added: { name: string; color: string }[]; + removed: { name: string; color: string }[]; + createdAt: string; +}; + +export type GroupedReviewRequestEvent = { + actor: GitHubActor | null; + requested: (GitHubActor | { login: string })[]; + removed: (GitHubActor | { login: string })[]; + createdAt: string; +}; + export type IssuePageData = { detail: IssueDetail | null; comments: IssueComment[]; @@ -203,6 +217,7 @@ export type PullStatus = { checkRuns: PullCheckRun[]; mergeable: boolean | null; mergeableState: string | null; + conflictingFiles: string[]; behindBy: number | null; baseRefName: string; canUpdateBranch: boolean; From 69f371c0e22ebaee90a32e7bdd4bcadbdbb74c7a Mon Sep 17 00:00:00 2001 From: Alan Daniel Date: Mon, 13 Apr 2026 21:05:38 -0400 Subject: [PATCH 2/2] Fix fork PR support: CI triggers, head branch label, and repo tab query pruning - Run CI checks on fork PRs via pull_request_target with explicit activity types - Show username:branch for fork PR head refs in detail header - Add repo tab matching to prevent tree queries from being pruned mid-flight --- .github/workflows/pr-checks.yml | 14 +++++++++++++ .../pulls/detail/pull-detail-header.tsx | 9 ++++++++- apps/dashboard/src/lib/github.functions.ts | 4 ++++ apps/dashboard/src/lib/github.types.ts | 1 + apps/dashboard/src/lib/query-client.tsx | 20 +++++++++++++++++++ 5 files changed, 47 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index a8b3287..a6e4bc2 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -2,6 +2,9 @@ name: PR Checks on: pull_request: + types: [opened, synchronize, reopened] + pull_request_target: + types: [opened, synchronize, reopened] concurrency: group: pr-checks-${{ github.event.pull_request.number || github.ref }} @@ -11,10 +14,16 @@ jobs: lint: name: Lint runs-on: ubuntu-latest + # Only run once: pull_request for same-repo PRs, pull_request_target for forks + if: >- + (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) || + (github.event_name == 'pull_request_target' && github.event.pull_request.head.repo.full_name != github.repository) steps: - name: Checkout uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} - name: Setup pnpm uses: pnpm/action-setup@v4 @@ -36,10 +45,15 @@ jobs: typecheck: name: Type Check runs-on: ubuntu-latest + if: >- + (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) || + (github.event_name == 'pull_request_target' && github.event.pull_request.head.repo.full_name != github.repository) steps: - name: Checkout uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} - name: Setup pnpm uses: pnpm/action-setup@v4 diff --git a/apps/dashboard/src/components/pulls/detail/pull-detail-header.tsx b/apps/dashboard/src/components/pulls/detail/pull-detail-header.tsx index f271f0b..1d00804 100644 --- a/apps/dashboard/src/components/pulls/detail/pull-detail-header.tsx +++ b/apps/dashboard/src/components/pulls/detail/pull-detail-header.tsx @@ -66,7 +66,14 @@ export function PullDetailHeader({ wants to merge into from - + )} diff --git a/apps/dashboard/src/lib/github.functions.ts b/apps/dashboard/src/lib/github.functions.ts index 1e3e026..469cf8f 100644 --- a/apps/dashboard/src/lib/github.functions.ts +++ b/apps/dashboard/src/lib/github.functions.ts @@ -282,6 +282,7 @@ type GitHubGraphQLPullPageResponse = { } | null; headRefName: string; headRefOid: string; + headRepositoryOwner: { login: string } | null; baseRefName: string; merged: boolean; mergeCommit: { oid: string } | null; @@ -868,6 +869,7 @@ function mapPullDetail( reviewComments: pull.review_comments, headRefName: pull.head.ref, headSha: pull.head.sha, + headRepoOwner: pull.head.repo?.owner?.login ?? null, baseRefName: pull.base.ref, isMerged: pull.merged, mergeCommitSha: pull.merge_commit_sha ?? null, @@ -1113,6 +1115,7 @@ function mapGraphQLPullDetail( reviewComments: pull.reviewThreads.totalCount, headRefName: pull.headRefName, headSha: pull.headRefOid, + headRepoOwner: pull.headRepositoryOwner?.login ?? null, baseRefName: pull.baseRefName, isMerged: pull.merged, mergeCommitSha: pull.mergeCommit?.oid ?? null, @@ -3313,6 +3316,7 @@ async function getPullPageDataViaGraphQL( } headRefName headRefOid + headRepositoryOwner { login } baseRefName merged mergeCommit { oid } diff --git a/apps/dashboard/src/lib/github.types.ts b/apps/dashboard/src/lib/github.types.ts index 5630db1..4f99973 100644 --- a/apps/dashboard/src/lib/github.types.ts +++ b/apps/dashboard/src/lib/github.types.ts @@ -71,6 +71,7 @@ export type PullDetail = PullSummary & { reviewComments: number; headRefName: string; headSha: string; + headRepoOwner: string | null; baseRefName: string; isMerged: boolean; mergeCommitSha: string | null; diff --git a/apps/dashboard/src/lib/query-client.tsx b/apps/dashboard/src/lib/query-client.tsx index 110ecda..dba757a 100644 --- a/apps/dashboard/src/lib/query-client.tsx +++ b/apps/dashboard/src/lib/query-client.tsx @@ -49,6 +49,17 @@ function isIssueQueryKeyInput( ); } +function isRepoQueryKeyInput( + value: unknown, +): value is { owner: string; repo: string } { + return Boolean( + value && + typeof value === "object" && + typeof (value as { owner?: unknown }).owner === "string" && + typeof (value as { repo?: unknown }).repo === "string", + ); +} + function matchesTabQuery(queryKey: readonly unknown[], tab: Tab) { const resourceType = queryKey[2]; const resourceName = queryKey[3]; @@ -69,6 +80,15 @@ function matchesTabQuery(queryKey: readonly unknown[], tab: Tab) { ); } + if (tab.type === "repo") { + return ( + resourceType === "repo" && + isRepoQueryKeyInput(input) && + input.owner === owner && + input.repo === repo + ); + } + return ( resourceType === "issues" && (resourceName === "page" ||