Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 1 addition & 14 deletions .github/workflows/pr-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
114 changes: 114 additions & 0 deletions apps/dashboard/src/components/details/comment-reply-form.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex w-full flex-col gap-2 pt-2">
<MarkdownEditor
value={value}
onChange={setValue}
placeholder={`Reply to @${parentAuthor}...`}
compact
/>
<div className="flex items-center justify-end gap-2">
<button
type="button"
onClick={() => {
setValue("");
onClose();
}}
className="rounded-md px-3 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
>
Cancel
</button>
<button
type="button"
onClick={() => void handleSend()}
disabled={!value.trim() || isSending}
className="flex items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs font-medium text-background transition-opacity disabled:opacity-50"
>
{isSending ? (
<Spinner className="size-3" />
) : (
<CommentIcon size={12} strokeWidth={2} />
)}
Reply
</button>
</div>
</div>
);
}
78 changes: 78 additions & 0 deletions apps/dashboard/src/components/details/comment-threads.ts
Original file line number Diff line number Diff line change
@@ -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<T extends CommentLike>(
comments: T[],
): {
repliesByCommentId: Map<number, T[]>;
replyIds: Set<number>;
} {
const repliesByCommentId = new Map<number, T[]>();
const replyIds = new Set<number>();
const commentById = new Map<number, T>();

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 };
}
Loading
Loading