Add GitHub issues support#1
Conversation
This change adds support for fetching and formatting GitHub Issues and their comments, similar to the existing Pull Request support. Changes: - Modified `apps/server/lib/github.ts` to include `Issue` and `IssueComment` interfaces and API methods. - Modified `apps/server/app.ts` to add endpoints for issues and format issue comments. - Added integration tests in `apps/server/tests/issue.test.ts`. - Updated `apps/server/package.json` with test script and dependencies. - Updated root `package.json` with test dependencies.
This change updates the GitHub API client to request 100 items per page for comments and reviews. This prevents issues where comments were silently truncated at the default limit of 30. Changes: - Updated `fetchPullRequestComments` to include `per_page=100`. - Updated `fetchPullRequestReviews` to include `per_page=100`. - Updated `fetchIssueComments` to include `per_page=100`.
This change implements full pagination support for GitHub API requests using the Link header. This ensures that all comments and reviews are fetched, not just the first 100. Changes: - Added `fetchPaginated` helper function in `apps/server/lib/github.ts`. - Updated `fetchPullRequestComments`, `fetchPullRequestReviews`, and `fetchIssueComments` to use `fetchPaginated`. - Added `apps/server/tests/pagination.test.ts` to verify pagination logic. - Increased timeout for pagination tests to handle multiple requests.
|
@0xdhrv is attempting to deploy a commit to the exon Team on Vercel. A member of the Team first needs to authorize it. |
📝 WalkthroughWalkthroughThe pull request introduces GitHub Issues endpoint support to the server, adding new type definitions (Issue, IssueComment), a generic pagination helper for GitHub API requests, issue fetching functions, and HTTP routes with Markdown rendering and error handling. Changes
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Poem
🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches
🧪 Generate unit tests (beta)
Tip Issue Planner is now in beta. Read the docs and try it out! Share your feedback on Discord. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (4)
package.json (1)
12-14: DuplicateddevDependencies— these already exist inapps/server/package.json.In a Yarn/npm workspaces monorepo, test dependencies that are only consumed by
apps/servershould live exclusively in that workspace'spackage.json. Having them duplicated at the root can cause version drift and confusion about which package actually depends on them. Consider removing them here.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@package.json` around lines 12 - 14, Remove the duplicated test devDependencies from the root package.json: delete the "@types/supertest" and "supertest" entries under "devDependencies" in this file and keep those dependencies declared only in apps/server/package.json, ensuring the workspace relies on the workspace-level declaration to avoid version drift and ambiguity.apps/server/lib/github.ts (2)
122-154:any[]types inIssueinterface reduce type safety.
labelsandmilestoneare typed asany[]/any | null. If the shape is known (even partially), consider at least a minimal interface (e.g.,{ id: number; name: string }for labels) to catch misuse at compile time. Fine to defer if these fields are unused today.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/server/lib/github.ts` around lines 122 - 154, The Issue interface currently weakens type safety by using any[] for labels and any | null for milestone; define small, explicit types (e.g., Label with id:number and name:string, optional color/string fields; Milestone with id:number, title:string, optional description/string and due_on/date) and replace labels: any[] with labels: Label[] and milestone: any | null with milestone: Milestone | null in the Issue interface (update any related usages/imports and adjust types in functions that construct or read Issue.labels and Issue.milestone such as where Issue objects are parsed or returned).
192-243: Consider adding a max-page safeguard tofetchPaginated.There's no upper bound on the number of pages fetched. A repo with many thousands of comments could cause this loop to make hundreds of sequential API calls, risking rate-limit exhaustion and long request latency. A simple
maxPagesparameter (with a sensible default, e.g., 50) would prevent runaway pagination.Proposed fix
-async function fetchPaginated<T>(url: string, token: string): Promise<T[]> { +async function fetchPaginated<T>(url: string, token: string, maxPages: number = 50): Promise<T[]> { let results: T[] = []; let nextUrl: string | null = url; + let page = 0; - while (nextUrl) { + while (nextUrl && page < maxPages) { + page++; const response = await fetch(nextUrl, {🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/server/lib/github.ts` around lines 192 - 243, Add a bounded pagination safeguard to fetchPaginated by adding an optional maxPages parameter (number) with a sensible default (e.g., 50) to the function signature and tracking a pagesFetched counter inside the while (nextUrl) loop; increment pagesFetched each iteration and break or throw a clear error once pagesFetched >= maxPages to avoid runaway requests, ensuring you still return accumulated results or surface a descriptive error if the limit is hit. Reference: function fetchPaginated, variables nextUrl and results, introduce pagesFetched and maxPages.apps/server/app.ts (1)
483-500: Parallelize independentgetIssueCommentsandgetIssuecalls.
getIssueCommentsandgetIssueare independent API calls but are executed sequentially, doubling latency for the "all comments" view. UsePromise.allto fetch both concurrently.Proposed fix
try { const client = new GitHubClient(token); - let comments = await client.getIssueComments(owner, repo, issueNum); - let issue: Issue | null = null; if (commentNum !== null) { + let comments = await client.getIssueComments(owner, repo, issueNum); if (commentNum < 1 || commentNum > comments.length) { const errorMarkdown = `# Error 404\n\nComment not found.\n\n**Comment Number:** ${commentNum}\n**Issue:** #${issueNum}\n**Total Comments:** ${comments.length}\n\nComment number must be between 1 and ${comments.length}.`; return res.status(404) .set("Content-Type", "text/markdown; charset=utf-8") .send(errorMarkdown); } comments = [comments[commentNum - 1]!]; + const markdown = formatIssueAsMarkdown(owner, repo, issueNum, null, comments); + return res.status(200) + .set("Content-Type", "text/markdown; charset=utf-8") + .send(markdown); } else { - // Fetch issue details only if not fetching a specific comment (or maybe fetch it anyway?) - // Fetching it anyway to include in the full view - issue = await client.getIssue(owner, repo, issueNum); + const [comments, issue] = await Promise.all([ + client.getIssueComments(owner, repo, issueNum), + client.getIssue(owner, repo, issueNum), + ]); + const markdown = formatIssueAsMarkdown(owner, repo, issueNum, issue, comments); + return res.status(200) + .set("Content-Type", "text/markdown; charset=utf-8") + .send(markdown); } - - const markdown = formatIssueAsMarkdown(owner, repo, issueNum, issue, comments); - - return res.status(200) - .set("Content-Type", "text/markdown; charset=utf-8") - .send(markdown);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/server/app.ts` around lines 483 - 500, The code currently calls client.getIssueComments(...) then awaits client.getIssue(...) sequentially when commentNum is null; change it to run them in parallel using Promise.all to reduce latency: for the branch where commentNum === null, call Promise.all([client.getIssueComments(owner, repo, issueNum), client.getIssue(owner, repo, issueNum)]) and destructure the results into comments and issue respectively; keep the existing behavior for the commentNum !== null branch (only fetch comments), and ensure the surrounding try/catch and variable names (GitHubClient, getIssueComments, getIssue, comments, issue, commentNum) are preserved.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@apps/server/app.ts`:
- Around line 263-317: In formatIssueAsMarkdown, add an explicit "no comments"
message when an issue exists but comments is empty: after the block that appends
the issue description (inside function formatIssueAsMarkdown, following the
lines that append `---\n\n` for the issue), check if comments.length === 0 and
if so append `*No comments found for this issue.*\n` (or similar) and return the
markdown; also update the existing condition that currently checks `if
(comments.length === 0 && !issue)` so it only handles the case where there is no
issue and no comments.
In `@apps/server/tests/issue.test.ts`:
- Around line 126-136: The test "GET /owner/repo/issues/123 without token
returns 401" mutates process.env.GITHUB_TOKEN but only restores it after the
assertion, so a thrown error can leak state; update the test to save
originalToken, delete process.env.GITHUB_TOKEN, then execute the
request(app).get("/owner/repo/issues/123").expect(401) inside a try block and
restore process.env.GITHUB_TOKEN in a finally block so the environment is always
restored regardless of test outcome.
---
Nitpick comments:
In `@apps/server/app.ts`:
- Around line 483-500: The code currently calls client.getIssueComments(...)
then awaits client.getIssue(...) sequentially when commentNum is null; change it
to run them in parallel using Promise.all to reduce latency: for the branch
where commentNum === null, call Promise.all([client.getIssueComments(owner,
repo, issueNum), client.getIssue(owner, repo, issueNum)]) and destructure the
results into comments and issue respectively; keep the existing behavior for the
commentNum !== null branch (only fetch comments), and ensure the surrounding
try/catch and variable names (GitHubClient, getIssueComments, getIssue,
comments, issue, commentNum) are preserved.
In `@apps/server/lib/github.ts`:
- Around line 122-154: The Issue interface currently weakens type safety by
using any[] for labels and any | null for milestone; define small, explicit
types (e.g., Label with id:number and name:string, optional color/string fields;
Milestone with id:number, title:string, optional description/string and
due_on/date) and replace labels: any[] with labels: Label[] and milestone: any |
null with milestone: Milestone | null in the Issue interface (update any related
usages/imports and adjust types in functions that construct or read Issue.labels
and Issue.milestone such as where Issue objects are parsed or returned).
- Around line 192-243: Add a bounded pagination safeguard to fetchPaginated by
adding an optional maxPages parameter (number) with a sensible default (e.g.,
50) to the function signature and tracking a pagesFetched counter inside the
while (nextUrl) loop; increment pagesFetched each iteration and break or throw a
clear error once pagesFetched >= maxPages to avoid runaway requests, ensuring
you still return accumulated results or surface a descriptive error if the limit
is hit. Reference: function fetchPaginated, variables nextUrl and results,
introduce pagesFetched and maxPages.
In `@package.json`:
- Around line 12-14: Remove the duplicated test devDependencies from the root
package.json: delete the "@types/supertest" and "supertest" entries under
"devDependencies" in this file and keep those dependencies declared only in
apps/server/package.json, ensuring the workspace relies on the workspace-level
declaration to avoid version drift and ambiguity.
| function formatIssueAsMarkdown( | ||
| owner: string, | ||
| repo: string, | ||
| issueNumber: number, | ||
| issue: Issue | null, | ||
| comments: IssueComment[] | ||
| ): string { | ||
| // Calculate tokens | ||
| let totalTokens = 0; | ||
| if (issue && issue.body) { | ||
| totalTokens += countTokens(issue.body); | ||
| } | ||
| comments.forEach(comment => { | ||
| totalTokens += countTokens(comment.body); | ||
| }); | ||
|
|
||
| let markdown = `# Issue #${issueNumber} Comments\n\n`; | ||
| markdown += `**Repository:** ${owner}/${repo}\n`; | ||
| markdown += `**Total Comments:** ${comments.length}\n`; | ||
| markdown += `**Total Tokens:** ${totalTokens.toLocaleString()}\n\n`; | ||
| markdown += `---\n\n`; | ||
|
|
||
| if (issue) { | ||
| markdown += `## Issue Description\n\n`; | ||
| markdown += `**Title:** ${issue.title}\n`; | ||
| markdown += `**Author:** @${issue.user.login}\n`; | ||
| markdown += `**Created:** ${new Date(issue.created_at).toLocaleString()}\n`; | ||
| markdown += `**State:** ${issue.state}\n`; | ||
| markdown += `**[View on GitHub](${issue.html_url})**\n\n`; | ||
| markdown += `### Description\n\n`; | ||
| markdown += `${issue.body || "*No description*"}\n\n`; | ||
| markdown += `---\n\n`; | ||
| } | ||
|
|
||
| if (comments.length === 0 && !issue) { | ||
| markdown += `*No comments found for this issue.*\n`; | ||
| return markdown; | ||
| } | ||
|
|
||
| comments.forEach((comment, index) => { | ||
| markdown += `## Comment ${index + 1}\n\n`; | ||
| markdown += `**Author:** @${comment.user.login}\n`; | ||
| markdown += `**Created:** ${new Date(comment.created_at).toLocaleString()}\n`; | ||
| markdown += `**[View on GitHub](${comment.html_url})**\n\n`; | ||
|
|
||
| markdown += `### Comment\n\n`; | ||
| markdown += `${comment.body}\n\n`; | ||
|
|
||
| if (index < comments.length - 1) { | ||
| markdown += `---\n\n`; | ||
| } | ||
| }); | ||
|
|
||
| return markdown; | ||
| } |
There was a problem hiding this comment.
Minor: empty comments case has no explicit "no comments" message when issue is present.
When issue is non-null but comments is empty, the output ends after the issue description with no indication that there are no comments. The "no comments" message on line 298 only triggers when issue is also null. Consider adding a brief note for the issue && comments.length === 0 case.
Proposed fix
if (comments.length === 0 && !issue) {
markdown += `*No comments found for this issue.*\n`;
return markdown;
}
+
+ if (comments.length === 0) {
+ markdown += `*No comments found for this issue.*\n`;
+ return markdown;
+ }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| function formatIssueAsMarkdown( | |
| owner: string, | |
| repo: string, | |
| issueNumber: number, | |
| issue: Issue | null, | |
| comments: IssueComment[] | |
| ): string { | |
| // Calculate tokens | |
| let totalTokens = 0; | |
| if (issue && issue.body) { | |
| totalTokens += countTokens(issue.body); | |
| } | |
| comments.forEach(comment => { | |
| totalTokens += countTokens(comment.body); | |
| }); | |
| let markdown = `# Issue #${issueNumber} Comments\n\n`; | |
| markdown += `**Repository:** ${owner}/${repo}\n`; | |
| markdown += `**Total Comments:** ${comments.length}\n`; | |
| markdown += `**Total Tokens:** ${totalTokens.toLocaleString()}\n\n`; | |
| markdown += `---\n\n`; | |
| if (issue) { | |
| markdown += `## Issue Description\n\n`; | |
| markdown += `**Title:** ${issue.title}\n`; | |
| markdown += `**Author:** @${issue.user.login}\n`; | |
| markdown += `**Created:** ${new Date(issue.created_at).toLocaleString()}\n`; | |
| markdown += `**State:** ${issue.state}\n`; | |
| markdown += `**[View on GitHub](${issue.html_url})**\n\n`; | |
| markdown += `### Description\n\n`; | |
| markdown += `${issue.body || "*No description*"}\n\n`; | |
| markdown += `---\n\n`; | |
| } | |
| if (comments.length === 0 && !issue) { | |
| markdown += `*No comments found for this issue.*\n`; | |
| return markdown; | |
| } | |
| comments.forEach((comment, index) => { | |
| markdown += `## Comment ${index + 1}\n\n`; | |
| markdown += `**Author:** @${comment.user.login}\n`; | |
| markdown += `**Created:** ${new Date(comment.created_at).toLocaleString()}\n`; | |
| markdown += `**[View on GitHub](${comment.html_url})**\n\n`; | |
| markdown += `### Comment\n\n`; | |
| markdown += `${comment.body}\n\n`; | |
| if (index < comments.length - 1) { | |
| markdown += `---\n\n`; | |
| } | |
| }); | |
| return markdown; | |
| } | |
| function formatIssueAsMarkdown( | |
| owner: string, | |
| repo: string, | |
| issueNumber: number, | |
| issue: Issue | null, | |
| comments: IssueComment[] | |
| ): string { | |
| // Calculate tokens | |
| let totalTokens = 0; | |
| if (issue && issue.body) { | |
| totalTokens += countTokens(issue.body); | |
| } | |
| comments.forEach(comment => { | |
| totalTokens += countTokens(comment.body); | |
| }); | |
| let markdown = `# Issue #${issueNumber} Comments\n\n`; | |
| markdown += `**Repository:** ${owner}/${repo}\n`; | |
| markdown += `**Total Comments:** ${comments.length}\n`; | |
| markdown += `**Total Tokens:** ${totalTokens.toLocaleString()}\n\n`; | |
| markdown += `---\n\n`; | |
| if (issue) { | |
| markdown += `## Issue Description\n\n`; | |
| markdown += `**Title:** ${issue.title}\n`; | |
| markdown += `**Author:** @${issue.user.login}\n`; | |
| markdown += `**Created:** ${new Date(issue.created_at).toLocaleString()}\n`; | |
| markdown += `**State:** ${issue.state}\n`; | |
| markdown += `**[View on GitHub](${issue.html_url})**\n\n`; | |
| markdown += `### Description\n\n`; | |
| markdown += `${issue.body || "*No description*"}\n\n`; | |
| markdown += `---\n\n`; | |
| } | |
| if (comments.length === 0 && !issue) { | |
| markdown += `*No comments found for this issue.*\n`; | |
| return markdown; | |
| } | |
| if (comments.length === 0) { | |
| markdown += `*No comments found for this issue.*\n`; | |
| return markdown; | |
| } | |
| comments.forEach((comment, index) => { | |
| markdown += `## Comment ${index + 1}\n\n`; | |
| markdown += `**Author:** @${comment.user.login}\n`; | |
| markdown += `**Created:** ${new Date(comment.created_at).toLocaleString()}\n`; | |
| markdown += `**[View on GitHub](${comment.html_url})**\n\n`; | |
| markdown += `### Comment\n\n`; | |
| markdown += `${comment.body}\n\n`; | |
| if (index < comments.length - 1) { | |
| markdown += `---\n\n`; | |
| } | |
| }); | |
| return markdown; | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/server/app.ts` around lines 263 - 317, In formatIssueAsMarkdown, add an
explicit "no comments" message when an issue exists but comments is empty: after
the block that appends the issue description (inside function
formatIssueAsMarkdown, following the lines that append `---\n\n` for the issue),
check if comments.length === 0 and if so append `*No comments found for this
issue.*\n` (or similar) and return the markdown; also update the existing
condition that currently checks `if (comments.length === 0 && !issue)` so it
only handles the case where there is no issue and no comments.
| test("GET /owner/repo/issues/123 without token returns 401", async () => { | ||
| // Ensure GITHUB_TOKEN is not set | ||
| const originalToken = process.env.GITHUB_TOKEN; | ||
| delete process.env.GITHUB_TOKEN; | ||
|
|
||
| await request(app) | ||
| .get("/owner/repo/issues/123") | ||
| .expect(401); | ||
|
|
||
| if (originalToken) process.env.GITHUB_TOKEN = originalToken; | ||
| }); |
There was a problem hiding this comment.
process.env.GITHUB_TOKEN restoration not guaranteed on test failure.
If the assertion on line 132-133 throws, process.env.GITHUB_TOKEN is never restored, which can leak state into subsequent tests. Wrap in try/finally.
Proposed fix
test("GET /owner/repo/issues/123 without token returns 401", async () => {
const originalToken = process.env.GITHUB_TOKEN;
delete process.env.GITHUB_TOKEN;
- await request(app)
- .get("/owner/repo/issues/123")
- .expect(401);
-
- if (originalToken) process.env.GITHUB_TOKEN = originalToken;
+ try {
+ await request(app)
+ .get("/owner/repo/issues/123")
+ .expect(401);
+ } finally {
+ if (originalToken) process.env.GITHUB_TOKEN = originalToken;
+ else delete process.env.GITHUB_TOKEN;
+ }
});🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/server/tests/issue.test.ts` around lines 126 - 136, The test "GET
/owner/repo/issues/123 without token returns 401" mutates
process.env.GITHUB_TOKEN but only restores it after the assertion, so a thrown
error can leak state; update the test to save originalToken, delete
process.env.GITHUB_TOKEN, then execute the
request(app).get("/owner/repo/issues/123").expect(401) inside a try block and
restore process.env.GITHUB_TOKEN in a finally block so the environment is always
restored regardless of test outcome.
20adc76 to
0cb0c3a
Compare
Summary by CodeRabbit
New Features
Tests