diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml
index a6e4bc2..72c7166 100644
--- a/.github/workflows/pr-checks.yml
+++ b/.github/workflows/pr-checks.yml
@@ -3,27 +3,19 @@ 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 }}
+ group: pr-checks-${{ github.event.pull_request.number }}
cancel-in-progress: true
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
@@ -45,15 +37,10 @@ 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/details/comment-reply-form.tsx b/apps/dashboard/src/components/details/comment-reply-form.tsx
new file mode 100644
index 0000000..0281525
--- /dev/null
+++ b/apps/dashboard/src/components/details/comment-reply-form.tsx
@@ -0,0 +1,114 @@
+import { CommentIcon } from "@diffkit/icons";
+import { MarkdownEditor } from "@diffkit/ui/components/markdown-editor";
+import { toast } from "@diffkit/ui/components/sonner";
+import { Spinner } from "@diffkit/ui/components/spinner";
+import { useQueryClient } from "@tanstack/react-query";
+import { useCallback, useState } from "react";
+import { createComment } from "#/lib/github.functions";
+import { githubQueryKeys } from "#/lib/github.query";
+import { checkPermissionWarning } from "#/lib/warning-store";
+
+export function CommentReplyForm({
+ owner,
+ repo,
+ issueNumber,
+ parentAuthor,
+ parentBody,
+ parentCommentId,
+ onClose,
+}: {
+ owner: string;
+ repo: string;
+ issueNumber: number;
+ parentAuthor: string;
+ parentBody: string;
+ parentCommentId: number;
+ onClose: () => void;
+}) {
+ const [value, setValue] = useState("");
+ const [isSending, setIsSending] = useState(false);
+ const queryClient = useQueryClient();
+
+ const handleSend = useCallback(async () => {
+ if (!value.trim()) return;
+ setIsSending(true);
+
+ const firstLines = parentBody
+ .split("\n")
+ .slice(0, 3)
+ .map((l) => `> ${l}`)
+ .join("\n");
+ const quoteBlock = `> **@${parentAuthor}** [commented](#issuecomment-${parentCommentId}):\n${firstLines}\n\n`;
+
+ try {
+ const result = await createComment({
+ data: {
+ owner,
+ repo,
+ issueNumber,
+ body: quoteBlock + value.trim(),
+ },
+ });
+ if (result.ok) {
+ setValue("");
+ onClose();
+ void queryClient.invalidateQueries({
+ queryKey: githubQueryKeys.all,
+ });
+ } else {
+ toast.error(result.error);
+ checkPermissionWarning(result, `${owner}/${repo}`);
+ }
+ } catch {
+ toast.error("Failed to send reply");
+ } finally {
+ setIsSending(false);
+ }
+ }, [
+ value,
+ parentAuthor,
+ parentBody,
+ parentCommentId,
+ owner,
+ repo,
+ issueNumber,
+ onClose,
+ queryClient,
+ ]);
+
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/dashboard/src/components/details/comment-threads.ts b/apps/dashboard/src/components/details/comment-threads.ts
new file mode 100644
index 0000000..7522dcf
--- /dev/null
+++ b/apps/dashboard/src/components/details/comment-threads.ts
@@ -0,0 +1,78 @@
+type CommentLike = {
+ id: number;
+ body: string;
+ author: { login: string } | null;
+};
+
+/**
+ * Detects reply relationships between comments by matching blockquote
+ * patterns: `> **@user** [commented](#issuecomment-ID):` or
+ * simple `> @user` prefix with quoted text matching a prior comment.
+ */
+export function buildCommentThreads(
+ comments: T[],
+): {
+ repliesByCommentId: Map;
+ replyIds: Set;
+} {
+ const repliesByCommentId = new Map();
+ const replyIds = new Set();
+ const commentById = new Map();
+
+ for (const c of comments) {
+ commentById.set(c.id, c);
+ }
+
+ for (const comment of comments) {
+ const body = comment.body;
+ if (!body.startsWith(">")) continue;
+
+ // Match our reply format: > **@user** [commented](#issuecomment-ID):
+ const linkMatch = body.match(
+ /^>\s*\*?\*?@(\S+)\*?\*?\s*\[commented\]\(#issuecomment-(\d+)\)/,
+ );
+ if (linkMatch) {
+ const parentId = Number(linkMatch[2]);
+ if (commentById.has(parentId)) {
+ const existing = repliesByCommentId.get(parentId) ?? [];
+ existing.push(comment);
+ repliesByCommentId.set(parentId, existing);
+ replyIds.add(comment.id);
+ continue;
+ }
+ }
+
+ // Fallback: match `> @user wrote:` or `> @user` pattern then fuzzy-match quoted text
+ const simpleMatch = body.match(/^>\s*@(\S+)/);
+ if (!simpleMatch) continue;
+
+ const quotedAuthor = simpleMatch[1].replace(/[*:]/g, "");
+ const quotedLines = body
+ .split("\n")
+ .filter((l) => l.startsWith("> "))
+ .map((l) => l.slice(2).trim())
+ .filter((l) => !l.startsWith("**@") && !l.startsWith("@"));
+
+ if (quotedLines.length === 0) continue;
+
+ const quotedSnippet = quotedLines[0].slice(0, 60);
+ if (quotedSnippet.length < 10) continue;
+
+ // Walk backwards looking for a matching parent
+ for (let i = comments.indexOf(comment) - 1; i >= 0; i--) {
+ const candidate = comments[i];
+ if (
+ candidate.author?.login === quotedAuthor &&
+ candidate.body.includes(quotedSnippet)
+ ) {
+ const existing = repliesByCommentId.get(candidate.id) ?? [];
+ existing.push(comment);
+ repliesByCommentId.set(candidate.id, existing);
+ replyIds.add(comment.id);
+ break;
+ }
+ }
+ }
+
+ return { repliesByCommentId, replyIds };
+}
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 108b267..960c9b7 100644
--- a/apps/dashboard/src/components/issues/detail/issue-detail-activity.tsx
+++ b/apps/dashboard/src/components/issues/detail/issue-detail-activity.tsx
@@ -1,6 +1,7 @@
import {
ChevronDownIcon,
CircleIcon,
+ CommentIcon,
EditIcon,
GitPullRequestIcon,
IssuesIcon,
@@ -10,8 +11,10 @@ import {
import { Markdown } from "@diffkit/ui/components/markdown";
import { cn } from "@diffkit/ui/lib/utils";
import { useQueryClient } from "@tanstack/react-query";
-import { useCallback, useRef, useState } from "react";
+import { useCallback, useMemo, useRef, useState } from "react";
import { CommentMoreMenu } from "#/components/details/comment-more-menu";
+import { CommentReplyForm } from "#/components/details/comment-reply-form";
+import { buildCommentThreads } from "#/components/details/comment-threads";
import {
DetailActivityHeader,
DetailCommentBox,
@@ -267,13 +270,20 @@ export function IssueDetailActivitySection({
issueAuthor: GitHubActor | null;
viewerLogin?: string;
}) {
+ const { repliesByCommentId, replyIds } = useMemo(
+ () => buildCommentThreads(comments ?? []),
+ [comments],
+ );
+
const allItems = groupTimelineEvents(
[
- ...(comments ?? []).map((comment) => ({
- type: "comment" as const,
- date: comment.createdAt,
- data: comment,
- })),
+ ...(comments ?? [])
+ .filter((c) => !replyIds.has(c.id))
+ .map((comment) => ({
+ type: "comment" as const,
+ date: comment.createdAt,
+ data: comment,
+ })),
...(events ?? []).map((event) => ({
type: "event" as const,
date: event.createdAt,
@@ -362,49 +372,18 @@ export function IssueDetailActivitySection({
const row = (() => {
if (item.type === "comment") {
const comment = item.data;
+ const replies = repliesByCommentId.get(comment.id);
return (
-
-
- {comment.author ? (
-

- ) : (
-
- )}
-
- {comment.author?.login ?? "Unknown"}
-
-
- {formatRelativeTime(comment.createdAt)}
-
-
-
-
-
-
- {comment.body}
-
-
+ comment={comment}
+ replies={replies}
+ isFirst={index === 0}
+ owner={owner}
+ repo={repo}
+ issueNumber={issueNumber}
+ viewerLogin={viewerLogin}
+ />
);
}
@@ -546,6 +525,154 @@ export function IssueDetailActivitySection({
);
}
+function IssueCommentBubble({
+ comment,
+ owner,
+ repo,
+ issueNumber,
+ viewerLogin,
+ onReply,
+ isReply,
+}: {
+ comment: IssueComment;
+ owner: string;
+ repo: string;
+ issueNumber: number;
+ viewerLogin?: string;
+ onReply?: () => void;
+ isReply?: boolean;
+}) {
+ return (
+
+
+ {comment.author ? (
+

+ ) : (
+
+ )}
+
+ {comment.author?.login ?? "Unknown"}
+
+
+ {formatRelativeTime(comment.createdAt)}
+
+
+ {onReply && (
+
+ )}
+
+
+
+
{comment.body}
+
+ );
+}
+
+function IssueCommentWithThread({
+ comment,
+ replies,
+ isFirst,
+ owner,
+ repo,
+ issueNumber,
+ viewerLogin,
+}: {
+ comment: IssueComment;
+ replies?: IssueComment[];
+ isFirst: boolean;
+ owner: string;
+ repo: string;
+ issueNumber: number;
+ viewerLogin?: string;
+}) {
+ const [showReplyForm, setShowReplyForm] = useState(false);
+ const hasReplies = replies && replies.length > 0;
+
+ return (
+
+
setShowReplyForm(true)}
+ />
+
+ {hasReplies && (
+
+ {replies.map((reply) => (
+ setShowReplyForm(true)}
+ isReply
+ />
+ ))}
+
+ )}
+
+ {showReplyForm && (
+
+ setShowReplyForm(false)}
+ />
+
+ )}
+
+ );
+}
+
function getInvolvedUsers(
issueAuthor: GitHubActor | null,
comments?: IssueComment[],
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 5518fce..2513974 100644
--- a/apps/dashboard/src/components/pulls/detail/pull-detail-activity.tsx
+++ b/apps/dashboard/src/components/pulls/detail/pull-detail-activity.tsx
@@ -50,6 +50,8 @@ import {
useState,
} from "react";
import { CommentMoreMenu } from "#/components/details/comment-more-menu";
+import { CommentReplyForm } from "#/components/details/comment-reply-form";
+import { buildCommentThreads } from "#/components/details/comment-threads";
import {
DetailActivityHeader,
DetailCommentBox,
@@ -1506,13 +1508,21 @@ function ActivityTimeline({
}
return { reviewCommentsByReviewId: byReview, repliesByCommentId: replies };
}, [reviewComments]);
+
+ const {
+ repliesByCommentId: issueCommentReplies,
+ replyIds: issueCommentReplyIds,
+ } = useMemo(() => buildCommentThreads(comments), [comments]);
+
const allItems = groupTimelineEvents(
[
- ...comments.map((comment) => ({
- type: "comment" as const,
- date: comment.createdAt,
- data: comment,
- })),
+ ...comments
+ .filter((c) => !issueCommentReplyIds.has(c.id))
+ .map((comment) => ({
+ type: "comment" as const,
+ date: comment.createdAt,
+ data: comment,
+ })),
...commits.map((commit) => ({
type: "commit" as const,
date: commit.createdAt,
@@ -1575,49 +1585,18 @@ function ActivityTimeline({
const row = (() => {
if (item.type === "comment") {
const comment = item.data;
+ const commentReplies = issueCommentReplies.get(comment.id);
return (
-
-
- {comment.author ? (
-

- ) : (
-
- )}
-
- {comment.author?.login ?? "Unknown"}
-
-
- {formatRelativeTime(comment.createdAt)}
-
-
-
-
-
-
- {comment.body}
-
-
+ comment={comment}
+ replies={commentReplies}
+ isFirst={index === 0}
+ owner={owner}
+ repo={repo}
+ pullNumber={pullNumber}
+ viewerLogin={viewerLogin}
+ />
);
}
@@ -2064,6 +2043,154 @@ function trimDiffHunk(
return [newHeader, ...trimmed].join("\n");
}
+function PullCommentBubble({
+ comment,
+ owner,
+ repo,
+ pullNumber,
+ viewerLogin,
+ onReply,
+ isReply,
+}: {
+ comment: PullComment;
+ owner: string;
+ repo: string;
+ pullNumber: number;
+ viewerLogin?: string;
+ onReply?: () => void;
+ isReply?: boolean;
+}) {
+ return (
+
+
+ {comment.author ? (
+

+ ) : (
+
+ )}
+
+ {comment.author?.login ?? "Unknown"}
+
+
+ {formatRelativeTime(comment.createdAt)}
+
+
+ {onReply && (
+
+ )}
+
+
+
+
{comment.body}
+
+ );
+}
+
+function PullCommentWithThread({
+ comment,
+ replies,
+ isFirst,
+ owner,
+ repo,
+ pullNumber,
+ viewerLogin,
+}: {
+ comment: PullComment;
+ replies?: PullComment[];
+ isFirst: boolean;
+ owner: string;
+ repo: string;
+ pullNumber: number;
+ viewerLogin?: string;
+}) {
+ const [showReplyForm, setShowReplyForm] = useState(false);
+ const hasReplies = replies && replies.length > 0;
+
+ return (
+
+
setShowReplyForm(true)}
+ />
+
+ {hasReplies && (
+
+ {replies.map((reply) => (
+
setShowReplyForm(true)}
+ isReply
+ />
+ ))}
+
+ )}
+
+ {showReplyForm && (
+
+ setShowReplyForm(false)}
+ />
+
+ )}
+
+ );
+}
+
function ReviewCommentBubble({
comment,
owner,
diff --git a/apps/dashboard/src/lib/github.functions.ts b/apps/dashboard/src/lib/github.functions.ts
index 469cf8f..4977474 100644
--- a/apps/dashboard/src/lib/github.functions.ts
+++ b/apps/dashboard/src/lib/github.functions.ts
@@ -2827,7 +2827,8 @@ function mapTimelineEvents(rawEvents: unknown[]): TimelineEvent[] {
return {
id: (raw.id as number) ?? 0,
event: raw.event as string,
- createdAt: (raw.created_at as string) ?? "",
+ createdAt:
+ (raw.created_at as string) ?? (raw.submitted_at as string) ?? "",
actor: actor
? {
login: (actor.login as string) ?? "",
@@ -3130,7 +3131,18 @@ async function computePullStatus(
});
}
- const checkRuns = checksResponse?.data.check_runs ?? [];
+ const allCheckRuns = checksResponse?.data.check_runs ?? [];
+
+ // Deduplicate by name — keep the most recent run (highest id) per check name
+ const latestByName = new Map();
+ for (const check of allCheckRuns) {
+ const existing = latestByName.get(check.name);
+ if (!existing || check.id > existing.id) {
+ latestByName.set(check.name, check);
+ }
+ }
+ const checkRuns = Array.from(latestByName.values());
+
let passed = 0;
let failed = 0;
let pending = 0;