|
| 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