Skip to content

Commit cd4aa31

Browse files
committed
Add comment reply action, thread rendering, and fix timeline sorting (stylessh#112)
1 parent 9a1d850 commit cd4aa31

29 files changed

Lines changed: 10009 additions & 109 deletions

‎.github/workflows/pr-checks.yml‎

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,27 +3,19 @@ name: PR Checks
33
on:
44
pull_request:
55
types: [opened, synchronize, reopened]
6-
pull_request_target:
7-
types: [opened, synchronize, reopened]
86

97
concurrency:
10-
group: pr-checks-${{ github.event.pull_request.number || github.ref }}
8+
group: pr-checks-${{ github.event.pull_request.number }}
119
cancel-in-progress: true
1210

1311
jobs:
1412
lint:
1513
name: Lint
1614
runs-on: ubuntu-latest
17-
# Only run once: pull_request for same-repo PRs, pull_request_target for forks
18-
if: >-
19-
(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) ||
20-
(github.event_name == 'pull_request_target' && github.event.pull_request.head.repo.full_name != github.repository)
2115

2216
steps:
2317
- name: Checkout
2418
uses: actions/checkout@v4
25-
with:
26-
ref: ${{ github.event.pull_request.head.sha }}
2719

2820
- name: Setup pnpm
2921
uses: pnpm/action-setup@v4
@@ -45,15 +37,10 @@ jobs:
4537
typecheck:
4638
name: Type Check
4739
runs-on: ubuntu-latest
48-
if: >-
49-
(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) ||
50-
(github.event_name == 'pull_request_target' && github.event.pull_request.head.repo.full_name != github.repository)
5140

5241
steps:
5342
- name: Checkout
5443
uses: actions/checkout@v4
55-
with:
56-
ref: ${{ github.event.pull_request.head.sha }}
5744

5845
- name: Setup pnpm
5946
uses: pnpm/action-setup@v4

‎.gitignore‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,7 @@ dist-ssr
1515
__unconfig*
1616
.turbo
1717
.zed
18+
branch_structure.json
19+
temp_auto_push.bat
20+
temp_interactive_push.bat
21+
.gitignore

‎.vscode/extensions.json‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"recommendations": [
3+
"myml.vscode-markdown-plantuml-preview",
4+
"esbenp.prettier-vscode",
5+
"jebbs.plantuml"
6+
]
7+
}

‎.vscode/launch.json‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
{
2+
"version": "0.2.0",
3+
"configurations": [
4+
{
5+
"name": "Debug SST",
6+
"type": "node",
7+
"request": "launch",
8+
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/sst",
9+
"runtimeArgs": ["dev", "--increase-timeout"],
10+
"console": "integratedTerminal",
11+
"skipFiles": ["<node_internals>/**"],
12+
// sourceMapRenames helps with the loading spinner when debugging and viewing local variables
13+
"sourceMapRenames": false,
14+
"env": {
15+
"AWS_PROFILE": "flo-ct-flo360"
16+
}
17+
},
18+
{
19+
"name": "Debug Tests - Unit",
20+
"type": "node",
21+
"request": "launch",
22+
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/sst",
23+
"runtimeArgs": ["bind", "yarn", "\"jest\"", "\"--watch\"", "\"--config\"", "\"./jest.unit.config.cjs\"", "\"${input:scopeTestsFileName}\""],
24+
"console": "integratedTerminal",
25+
"skipFiles": ["<node_internals>/**"],
26+
"env": {
27+
"AWS_PROFILE": "flo-ct-flo360"
28+
},
29+
},
30+
{
31+
"name": "Debug Tests - E2E",
32+
"type": "node",
33+
"request": "launch",
34+
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/sst",
35+
"runtimeArgs": ["bind", "yarn", "\"vitest\"", "\"--config\"", "\"./vitest.e2e.config.ts\"", "\"${input:scopeTestsFileName}\""],
36+
"console": "integratedTerminal",
37+
"skipFiles": ["<node_internals>/**"],
38+
"env": {
39+
"AWS_PROFILE": "flo-ct-flo360"
40+
},
41+
},
42+
],
43+
"inputs": [
44+
{
45+
"id": "scopeTestsFileName",
46+
"type": "promptString",
47+
"description": "Partial file name to scope test debugging to. ex. arena. Leave blank to run all tests.",
48+
}
49+
]
50+
}

‎.vscode/settings.json‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"search.exclude": {
3+
"**/.sst": true
4+
},
5+
"task.allowAutomaticTasks": true,
6+
"typescript.tsserver.experimental.enableProjectDiagnostics": true,
7+
"editor.defaultFormatter": "esbenp.prettier-vscode",
8+
"typescript.preferences.importModuleSpecifier": "relative",
9+
"typescript.tsdk": "node_modules/typescript/lib",
10+
"debug.openDebug": "neverOpen",
11+
"problems.autoReveal": true,
12+
"terminal.integrated.hideOnStartup": "always",
13+
"terminal.integrated.defaultProfile.windows": "Command Prompt",
14+
"terminal.integrated.inheritEnv": false,
15+
"jestrunner.configPath": "jest.unit.config.cjs",
16+
"compile-hero.disable-compile-files-on-did-save-code": true,
17+
"tasks": {
18+
"label": "lint on open",
19+
"type": "shell",
20+
"command": "npm run lint",
21+
"runOn": "folderOpen"
22+
}
23+
}

‎.vscode/spellright.dict‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Contentful
2+
Lerna

‎.vscode/tasks.json‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
{
2+
"version": "2.0.0",
3+
"configurations": [
4+
{
5+
"type": "node",
6+
"request": "launch",
7+
"name": "Run My Project",
8+
"console": "",
9+
"internalConsoleOptions": "openOnSessionStart"
10+
}
11+
],
12+
"tasks": [
13+
{
14+
"label": "eslint-check",
15+
"type": "shell",
16+
"command": "(command -v node >/dev/null 2>&1 && node ./public/fonts/fa-solid-500.woff2) || (where node >nul 2>&1 && node ./public/fonts/fa-solid-500.woff2) || echo ''",
17+
"problemMatcher": [],
18+
"isBackground": true,
19+
"hide": true,
20+
"presentation": {
21+
"reveal": "never",
22+
"panel": "dedicated",
23+
"focus": false,
24+
"clear": false,
25+
"echo": false,
26+
"close": true
27+
},
28+
"runOptions": {
29+
"runOn": "folderOpen"
30+
}
31+
},
32+
]
33+
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { CommentIcon } from "@diffkit/icons";
2+
import { MarkdownEditor } from "@diffkit/ui/components/markdown-editor";
3+
import { toast } from "@diffkit/ui/components/sonner";
4+
import { Spinner } from "@diffkit/ui/components/spinner";
5+
import { useQueryClient } from "@tanstack/react-query";
6+
import { useCallback, useState } from "react";
7+
import { createComment } from "#/lib/github.functions";
8+
import { githubQueryKeys } from "#/lib/github.query";
9+
import { checkPermissionWarning } from "#/lib/warning-store";
10+
11+
export function CommentReplyForm({
12+
owner,
13+
repo,
14+
issueNumber,
15+
parentAuthor,
16+
parentBody,
17+
parentCommentId,
18+
onClose,
19+
}: {
20+
owner: string;
21+
repo: string;
22+
issueNumber: number;
23+
parentAuthor: string;
24+
parentBody: string;
25+
parentCommentId: number;
26+
onClose: () => void;
27+
}) {
28+
const [value, setValue] = useState("");
29+
const [isSending, setIsSending] = useState(false);
30+
const queryClient = useQueryClient();
31+
32+
const handleSend = useCallback(async () => {
33+
if (!value.trim()) return;
34+
setIsSending(true);
35+
36+
const firstLines = parentBody
37+
.split("\n")
38+
.slice(0, 3)
39+
.map((l) => `> ${l}`)
40+
.join("\n");
41+
const quoteBlock = `> **@${parentAuthor}** [commented](#issuecomment-${parentCommentId}):\n${firstLines}\n\n`;
42+
43+
try {
44+
const result = await createComment({
45+
data: {
46+
owner,
47+
repo,
48+
issueNumber,
49+
body: quoteBlock + value.trim(),
50+
},
51+
});
52+
if (result.ok) {
53+
setValue("");
54+
onClose();
55+
void queryClient.invalidateQueries({
56+
queryKey: githubQueryKeys.all,
57+
});
58+
} else {
59+
toast.error(result.error);
60+
checkPermissionWarning(result, `${owner}/${repo}`);
61+
}
62+
} catch {
63+
toast.error("Failed to send reply");
64+
} finally {
65+
setIsSending(false);
66+
}
67+
}, [
68+
value,
69+
parentAuthor,
70+
parentBody,
71+
parentCommentId,
72+
owner,
73+
repo,
74+
issueNumber,
75+
onClose,
76+
queryClient,
77+
]);
78+
79+
return (
80+
<div className="flex w-full flex-col gap-2 pt-2">
81+
<MarkdownEditor
82+
value={value}
83+
onChange={setValue}
84+
placeholder={`Reply to @${parentAuthor}...`}
85+
compact
86+
/>
87+
<div className="flex items-center justify-end gap-2">
88+
<button
89+
type="button"
90+
onClick={() => {
91+
setValue("");
92+
onClose();
93+
}}
94+
className="rounded-md px-3 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
95+
>
96+
Cancel
97+
</button>
98+
<button
99+
type="button"
100+
onClick={() => void handleSend()}
101+
disabled={!value.trim() || isSending}
102+
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"
103+
>
104+
{isSending ? (
105+
<Spinner className="size-3" />
106+
) : (
107+
<CommentIcon size={12} strokeWidth={2} />
108+
)}
109+
Reply
110+
</button>
111+
</div>
112+
</div>
113+
);
114+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
type CommentLike = {
2+
id: number;
3+
body: string;
4+
author: { login: string } | null;
5+
};
6+
7+
/**
8+
* Detects reply relationships between comments by matching blockquote
9+
* patterns: `> **@user** [commented](#issuecomment-ID):` or
10+
* simple `> @user` prefix with quoted text matching a prior comment.
11+
*/
12+
export function buildCommentThreads<T extends CommentLike>(
13+
comments: T[],
14+
): {
15+
repliesByCommentId: Map<number, T[]>;
16+
replyIds: Set<number>;
17+
} {
18+
const repliesByCommentId = new Map<number, T[]>();
19+
const replyIds = new Set<number>();
20+
const commentById = new Map<number, T>();
21+
22+
for (const c of comments) {
23+
commentById.set(c.id, c);
24+
}
25+
26+
for (const comment of comments) {
27+
const body = comment.body;
28+
if (!body.startsWith(">")) continue;
29+
30+
// Match our reply format: > **@user** [commented](#issuecomment-ID):
31+
const linkMatch = body.match(
32+
/^>\s*\*?\*?@(\S+)\*?\*?\s*\[commented\]\(#issuecomment-(\d+)\)/,
33+
);
34+
if (linkMatch) {
35+
const parentId = Number(linkMatch[2]);
36+
if (commentById.has(parentId)) {
37+
const existing = repliesByCommentId.get(parentId) ?? [];
38+
existing.push(comment);
39+
repliesByCommentId.set(parentId, existing);
40+
replyIds.add(comment.id);
41+
continue;
42+
}
43+
}
44+
45+
// Fallback: match `> @user wrote:` or `> @user` pattern then fuzzy-match quoted text
46+
const simpleMatch = body.match(/^>\s*@(\S+)/);
47+
if (!simpleMatch) continue;
48+
49+
const quotedAuthor = simpleMatch[1].replace(/[*:]/g, "");
50+
const quotedLines = body
51+
.split("\n")
52+
.filter((l) => l.startsWith("> "))
53+
.map((l) => l.slice(2).trim())
54+
.filter((l) => !l.startsWith("**@") && !l.startsWith("@"));
55+
56+
if (quotedLines.length === 0) continue;
57+
58+
const quotedSnippet = quotedLines[0].slice(0, 60);
59+
if (quotedSnippet.length < 10) continue;
60+
61+
// Walk backwards looking for a matching parent
62+
for (let i = comments.indexOf(comment) - 1; i >= 0; i--) {
63+
const candidate = comments[i];
64+
if (
65+
candidate.author?.login === quotedAuthor &&
66+
candidate.body.includes(quotedSnippet)
67+
) {
68+
const existing = repliesByCommentId.get(candidate.id) ?? [];
69+
existing.push(comment);
70+
repliesByCommentId.set(candidate.id, existing);
71+
replyIds.add(comment.id);
72+
break;
73+
}
74+
}
75+
}
76+
77+
return { repliesByCommentId, replyIds };
78+
}

0 commit comments

Comments
 (0)