Skip to content

Commit c00195f

Browse files
committed
Add a bunch of features
1 parent 6912bf0 commit c00195f

File tree

16 files changed

+4245
-892
lines changed

16 files changed

+4245
-892
lines changed

bun.lock

Lines changed: 284 additions & 0 deletions
Large diffs are not rendered by default.

package.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,19 @@
2525
"dependencies": {
2626
"@radix-ui/react-collapsible": "^1.1.12",
2727
"@radix-ui/react-context-menu": "^2.2.16",
28+
"@radix-ui/react-dropdown-menu": "^2.1.16",
2829
"@radix-ui/react-slot": "^1.2.4",
2930
"@radix-ui/react-tooltip": "^1.2.8",
31+
"@tanstack/react-virtual": "^3.13.12",
3032
"clsx": "^2.1.1",
33+
"cmdk": "^1.1.1",
3134
"diff": "^8.0.2",
3235
"gitdiff-parser": "^0.3.1",
3336
"lucide-react": "^0.555.0",
37+
"react-markdown": "^10.1.0",
3438
"react-router-dom": "^7.9.6",
3539
"refractor": "^5.0.0",
40+
"remark-gfm": "^4.0.1",
3641
"tailwind-merge": "^3.4.0",
3742
"tw-animate-css": "^1.4.0"
3843
},
@@ -43,6 +48,7 @@
4348
"@types/react-dom": "^19",
4449
"bun-plugin-tailwind": "latest",
4550
"concurrently": "^9.2.1",
51+
"geist": "^1.5.1",
4652
"hono": "^4.10.7",
4753
"react": "19.2.0",
4854
"react-dom": "19.2.0",

src/api/api.ts

Lines changed: 230 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,20 @@ import {
1919
getIssueComments,
2020
createIssueComment,
2121
getFileContent,
22+
getCurrentUser,
23+
// GraphQL mutations for pending reviews
24+
getPullRequestNodeId,
25+
getPendingReviewNodeId,
26+
addPendingReviewCommentGraphQL,
27+
deletePendingReviewCommentGraphQL,
28+
updatePendingReviewCommentGraphQL,
29+
submitPendingReviewGraphQL,
30+
// Review thread resolution
31+
getReviewThreads,
32+
resolveReviewThread,
33+
unresolveReviewThread,
2234
} from "./github";
23-
import { parseDiffWithHighlighting } from "./diff";
35+
import { parseDiffWithHighlighting, highlightFileLines } from "./diff";
2436

2537
const api = new Hono().basePath("/api");
2638

@@ -52,16 +64,43 @@ api.get("/pr/:owner/:repo/:number/files", async (c) => {
5264
}
5365
});
5466

55-
// Get PR comments
67+
// Get PR comments (with thread resolution info from GraphQL)
5668
api.get("/pr/:owner/:repo/:number/comments", async (c) => {
5769
const { owner, repo, number } = c.req.param();
5870
try {
59-
const comments = await getPullRequestComments(
60-
owner,
61-
repo,
62-
parseInt(number, 10)
63-
);
64-
return c.json(comments);
71+
// Fetch both REST comments and GraphQL threads for resolution info
72+
const [comments, threads] = await Promise.all([
73+
getPullRequestComments(owner, repo, parseInt(number, 10)),
74+
getReviewThreads(owner, repo, parseInt(number, 10)),
75+
]);
76+
77+
// Create a map of comment database ID to thread info
78+
const commentToThread = new Map<number, { threadId: string; isResolved: boolean; resolvedBy: { login: string; avatar_url: string } | null }>();
79+
for (const thread of threads) {
80+
for (const comment of thread.comments) {
81+
commentToThread.set(comment.databaseId, {
82+
threadId: thread.id,
83+
isResolved: thread.isResolved,
84+
resolvedBy: thread.resolvedBy ? {
85+
login: thread.resolvedBy.login,
86+
avatar_url: thread.resolvedBy.avatarUrl,
87+
} : null,
88+
});
89+
}
90+
}
91+
92+
// Enrich REST comments with thread info
93+
const enrichedComments = comments.map((comment) => {
94+
const threadInfo = commentToThread.get(comment.id);
95+
return {
96+
...comment,
97+
pull_request_review_thread_id: threadInfo?.threadId,
98+
is_resolved: threadInfo?.isResolved ?? false,
99+
resolved_by: threadInfo?.resolvedBy ?? null,
100+
};
101+
});
102+
103+
return c.json(enrichedComments);
65104
} catch (error) {
66105
return c.json(
67106
{ error: error instanceof Error ? error.message : "Failed to fetch comments" },
@@ -70,6 +109,48 @@ api.get("/pr/:owner/:repo/:number/comments", async (c) => {
70109
}
71110
});
72111

112+
// Get review threads (GraphQL - includes resolution status)
113+
api.get("/pr/:owner/:repo/:number/threads", async (c) => {
114+
const { owner, repo, number } = c.req.param();
115+
try {
116+
const threads = await getReviewThreads(owner, repo, parseInt(number, 10));
117+
return c.json(threads);
118+
} catch (error) {
119+
return c.json(
120+
{ error: error instanceof Error ? error.message : "Failed to fetch threads" },
121+
500
122+
);
123+
}
124+
});
125+
126+
// Resolve a review thread
127+
api.post("/pr/:owner/:repo/:number/threads/:threadId/resolve", async (c) => {
128+
const { threadId } = c.req.param();
129+
try {
130+
await resolveReviewThread(threadId);
131+
return c.json({ success: true });
132+
} catch (error) {
133+
return c.json(
134+
{ error: error instanceof Error ? error.message : "Failed to resolve thread" },
135+
500
136+
);
137+
}
138+
});
139+
140+
// Unresolve a review thread
141+
api.post("/pr/:owner/:repo/:number/threads/:threadId/unresolve", async (c) => {
142+
const { threadId } = c.req.param();
143+
try {
144+
await unresolveReviewThread(threadId);
145+
return c.json({ success: true });
146+
} catch (error) {
147+
return c.json(
148+
{ error: error instanceof Error ? error.message : "Failed to unresolve thread" },
149+
500
150+
);
151+
}
152+
});
153+
73154
// Create PR comment
74155
api.post("/pr/:owner/:repo/:number/comments", async (c) => {
75156
const { owner, repo, number } = c.req.param();
@@ -177,7 +258,7 @@ api.post("/pr/:owner/:repo/:number/reviews", async (c) => {
177258
}
178259
});
179260

180-
// Create a pending review
261+
// Create a pending review (REST API - legacy)
181262
api.post("/pr/:owner/:repo/:number/reviews/pending", async (c) => {
182263
const { owner, repo, number } = c.req.param();
183264
const body = await c.req.json();
@@ -198,6 +279,110 @@ api.post("/pr/:owner/:repo/:number/reviews/pending", async (c) => {
198279
}
199280
});
200281

282+
// Get PR node ID (for GraphQL mutations)
283+
api.get("/pr/:owner/:repo/:number/node-id", async (c) => {
284+
const { owner, repo, number } = c.req.param();
285+
286+
try {
287+
const nodeId = await getPullRequestNodeId(owner, repo, parseInt(number, 10));
288+
return c.json({ nodeId });
289+
} catch (error) {
290+
return c.json(
291+
{ error: error instanceof Error ? error.message : "Failed to get PR node ID" },
292+
500
293+
);
294+
}
295+
});
296+
297+
// Get user's pending review via GraphQL
298+
api.get("/pr/:owner/:repo/:number/pending-review", async (c) => {
299+
const { owner, repo, number } = c.req.param();
300+
301+
try {
302+
const result = await getPendingReviewNodeId(owner, repo, parseInt(number, 10));
303+
return c.json(result);
304+
} catch (error) {
305+
return c.json(
306+
{ error: error instanceof Error ? error.message : "Failed to get pending review" },
307+
500
308+
);
309+
}
310+
});
311+
312+
// Add a comment to pending review via GraphQL (creates review if needed)
313+
api.post("/pr/:owner/:repo/:number/pending-comment", async (c) => {
314+
const { owner, repo, number } = c.req.param();
315+
const body = await c.req.json();
316+
317+
try {
318+
// Get PR node ID if not provided
319+
let pullRequestId = body.pull_request_id;
320+
if (!pullRequestId) {
321+
pullRequestId = await getPullRequestNodeId(owner, repo, parseInt(number, 10));
322+
}
323+
324+
const result = await addPendingReviewCommentGraphQL(
325+
pullRequestId,
326+
body.path,
327+
body.line,
328+
body.body,
329+
body.start_line
330+
);
331+
return c.json(result);
332+
} catch (error) {
333+
return c.json(
334+
{ error: error instanceof Error ? error.message : "Failed to add pending comment" },
335+
500
336+
);
337+
}
338+
});
339+
340+
// Delete a pending review comment via GraphQL
341+
api.delete("/pr/:owner/:repo/:number/pending-comment/:commentId", async (c) => {
342+
const { commentId } = c.req.param();
343+
344+
try {
345+
await deletePendingReviewCommentGraphQL(commentId);
346+
return c.json({ success: true });
347+
} catch (error) {
348+
return c.json(
349+
{ error: error instanceof Error ? error.message : "Failed to delete pending comment" },
350+
500
351+
);
352+
}
353+
});
354+
355+
// Update a pending review comment via GraphQL
356+
api.patch("/pr/:owner/:repo/:number/pending-comment/:commentId", async (c) => {
357+
const { commentId } = c.req.param();
358+
const body = await c.req.json();
359+
360+
try {
361+
await updatePendingReviewCommentGraphQL(commentId, body.body);
362+
return c.json({ success: true });
363+
} catch (error) {
364+
return c.json(
365+
{ error: error instanceof Error ? error.message : "Failed to update pending comment" },
366+
500
367+
);
368+
}
369+
});
370+
371+
// Submit pending review via GraphQL
372+
api.post("/pr/:owner/:repo/:number/pending-review/submit", async (c) => {
373+
const body = await c.req.json();
374+
375+
try {
376+
await submitPendingReviewGraphQL(body.review_id, body.event, body.body);
377+
return c.json({ success: true });
378+
} catch (error) {
379+
return c.json(
380+
{ error: error instanceof Error ? error.message : "Failed to submit review" },
381+
500
382+
);
383+
}
384+
});
385+
201386
// Submit a pending review
202387
api.post("/pr/:owner/:repo/:number/reviews/:reviewId/submit", async (c) => {
203388
const { owner, repo, number, reviewId } = c.req.param();
@@ -410,4 +595,40 @@ api.get("/file/:owner/:repo", async (c) => {
410595
}
411596
});
412597

598+
// Highlight file lines (for skip block expansion)
599+
api.post("/highlight-lines", async (c) => {
600+
try {
601+
const body = await c.req.json();
602+
const { content, filename, startLine, count } = body;
603+
604+
if (!content || !filename || !startLine || !count) {
605+
return c.json({ error: "Missing required fields" }, 400);
606+
}
607+
608+
const lines = highlightFileLines(content, filename, startLine, count);
609+
return c.json(lines);
610+
} catch (error) {
611+
return c.json(
612+
{ error: error instanceof Error ? error.message : "Failed to highlight lines" },
613+
500
614+
);
615+
}
616+
});
617+
618+
// ============================================================================
619+
// User
620+
// ============================================================================
621+
622+
api.get("/user", async (c) => {
623+
try {
624+
const user = await getCurrentUser();
625+
return c.json(user);
626+
} catch (error) {
627+
return c.json(
628+
{ error: error instanceof Error ? error.message : "Failed to fetch user" },
629+
500
630+
);
631+
}
632+
});
633+
413634
export default api;

src/api/diff.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,36 @@ function highlight(code: string, lang: string): string {
136136
}
137137
}
138138

139+
/**
140+
* Highlight a range of lines from file content.
141+
* Returns an array of DiffLine objects with syntax highlighting.
142+
*/
143+
export function highlightFileLines(
144+
content: string,
145+
filename: string,
146+
startLine: number,
147+
count: number
148+
): DiffLine[] {
149+
const language = guessLang(filename);
150+
const allLines = content.split("\n");
151+
const result: DiffLine[] = [];
152+
153+
for (let i = 0; i < count; i++) {
154+
const lineNum = startLine + i;
155+
const lineContent = allLines[lineNum - 1] ?? "";
156+
const highlighted = highlight(lineContent, language);
157+
158+
result.push({
159+
type: "normal",
160+
oldLineNumber: lineNum,
161+
newLineNumber: lineNum,
162+
content: [{ value: lineContent, html: highlighted, type: "normal" }],
163+
});
164+
}
165+
166+
return result;
167+
}
168+
139169
// ============================================================================
140170
// Diff Parsing
141171
// ============================================================================

0 commit comments

Comments
 (0)