Skip to content

Commit

Permalink
Merge pull request #13 from presubmit/dev
Browse files Browse the repository at this point in the history
Added Pull Request Comment Handling Infrastructure
  • Loading branch information
bstanga authored Nov 21, 2024
2 parents 1faedbe + a49607d commit ba50bfd
Show file tree
Hide file tree
Showing 9 changed files with 426 additions and 139 deletions.
186 changes: 107 additions & 79 deletions dist/index.js

Large diffs are not rendered by default.

97 changes: 97 additions & 0 deletions src/comments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { Octokit } from "@octokit/action";
import { COMMENT_SIGNATURE } from "./messages";

export type ReviewComment = {
path: string;
body: string;
diff_hunk?: string;
line?: number;
in_reply_to_id?: number;
id: number;
start_line?: number | null;
user: {
login: string;
};
};

export type ReviewCommentThread = {
file: string;
comments: ReviewComment[];
};

export async function listPullRequestCommentThreads(
octokit: Octokit,
{
owner,
repo,
pull_number,
}: { owner: string; repo: string; pull_number: number }
): Promise<ReviewCommentThread[]> {
let { data: comments } = await octokit.rest.pulls.listReviewComments({
owner,
repo,
pull_number,
});

comments = comments.map((c) => ({
...c,
user: {
...c.user,
login: isOwnComment(c.body) ? "presubmit" : c.user.login,
},
}));

return generateCommentThreads(comments);
}

export async function getCommentThread(
octokit: Octokit,
{
owner,
repo,
pull_number,
comment_id,
}: { owner: string; repo: string; pull_number: number; comment_id: number }
): Promise<ReviewCommentThread | null> {
const threads = await listPullRequestCommentThreads(octokit, {
owner,
repo,
pull_number,
});
return (
threads.find((t) => t.comments.some((c) => c.id === comment_id)) || null
);
}

export function isThreadRelevant(thread: ReviewCommentThread): boolean {
return thread.comments.some(
(c) =>
c.body.includes(COMMENT_SIGNATURE) ||
c.body.includes("@presubmitai") ||
c.body.includes("@presubmit")
);
}

function generateCommentThreads(
reviewComments: ReviewComment[]
): ReviewCommentThread[] {
const topLevelComments = reviewComments.filter(
(c) => !c.in_reply_to_id && c.body.length && !!c.line
);

return topLevelComments.map((topLevelComment) => {
return {
file: topLevelComment.path,
comments: [
topLevelComment,
...reviewComments.filter(
(c) => c.in_reply_to_id === topLevelComment.id
),
],
};
});
}

export function isOwnComment(comment: string): boolean {
return comment.includes(COMMENT_SIGNATURE);
}
13 changes: 13 additions & 0 deletions src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,17 @@ async function loadDebugContext(): Promise<Context> {
pull_number: parseInt(process.env.GITHUB_PULL_REQUEST || "1"),
});

const commentId = process.env.GITHUB_COMMENT_ID;
let comment: any;
if (commentId) {
const { data } = await octokit.rest.pulls.getReviewComment({
owner,
repo,
comment_id: parseInt(commentId),
});
comment = data;
}

return {
...context,
eventName: process.env.GITHUB_EVENT_NAME || "",
Expand All @@ -30,12 +41,14 @@ async function loadDebugContext(): Promise<Context> {
repo,
},
payload: {
action: process.env.GITHUB_EVENT_ACTION || "",
pull_request: {
...pull_request,
number: pull_request.number,
html_url: pull_request.html_url,
body: pull_request.body || undefined,
},
comment,
},
issue: context.issue,
};
Expand Down
77 changes: 29 additions & 48 deletions src/diff.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { ReviewComment, ReviewCommentThread } from "./comments";

export type File = {
filename: string;
status:
Expand All @@ -16,29 +18,15 @@ export type Hunk = {
startLine: number;
endLine: number;
diff: string;
commentChains?: {
comments: ReviewComment[];
}[];
commentThreads?: ReviewCommentThread[];
};

export type FileDiff = File & {
hunks: Hunk[];
};

type ReviewComment = {
path: string;
body: string;
line?: number;
in_reply_to_id?: number;
id: number;
start_line?: number | null;
user: {
login: string;
};
};
export function parseFileDiff(
file: File,
reviewComments: ReviewComment[]
prCommentThreads: ReviewCommentThread[]
): FileDiff {
if (!file.patch) {
return {
Expand Down Expand Up @@ -75,7 +63,7 @@ export function parseFileDiff(
hunks = hunks.map((hunk) => {
return {
...hunk,
commentChains: generateCommentChains(file, hunk, reviewComments),
commentThreads: filterDiffHunkThreads(file, hunk, prCommentThreads),
};
});

Expand All @@ -85,35 +73,6 @@ export function parseFileDiff(
};
}

function generateCommentChains(
file: File,
hunk: Hunk,
reviewComments: ReviewComment[]
): { comments: ReviewComment[] }[] {
const topLevelComments = reviewComments.filter(
(c) =>
!c.in_reply_to_id &&
c.path === file.filename &&
c.body.length &&
c.line &&
c.line <= hunk.endLine &&
c.line >= hunk.startLine &&
(!c.start_line ||
(c.start_line <= hunk.endLine && c.start_line >= hunk.startLine))
);

return topLevelComments.map((topLevelComment) => {
return {
comments: [
topLevelComment,
...reviewComments.filter(
(c) => c.in_reply_to_id === topLevelComment.id
),
],
};
});
}

function removeDeletedLines(hunk: Hunk): Hunk {
return {
...hunk,
Expand Down Expand Up @@ -205,8 +164,8 @@ function formatDiffHunk(hunk: Hunk): string {
output += `__old hunk__\n${oldContent}\n`;
}

if (hunk.commentChains?.length) {
output += `__existing_comment_thread__\n${hunk.commentChains
if (hunk.commentThreads?.length) {
output += `__existing_comment_thread__\n${hunk.commentThreads
.map((c) =>
c.comments.map((c) => `@${c.user.login}: ${c.body}`).join("\n")
)
Expand Down Expand Up @@ -251,3 +210,25 @@ export function generateFileCodeDiff(fileDiff: FileDiff): string {

return header;
}

function filterDiffHunkThreads(
file: File,
hunk: Hunk,
prCommentThreads: ReviewCommentThread[]
): ReviewCommentThread[] {
return prCommentThreads.filter((t) => {
const c = t.comments[0];
return (
c &&
t.file === file.filename &&
!c.in_reply_to_id &&
c.path === file.filename &&
c.body.length &&
c.line &&
c.line <= hunk.endLine &&
c.line >= hunk.startLine &&
(!c.start_line ||
(c.start_line <= hunk.endLine && c.start_line >= hunk.startLine))
);
});
}
2 changes: 2 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { warning, setFailed } from "@actions/core";
import { handlePullRequest } from "./pull_request";
import { handlePullRequestComment } from "./pull_request_comment";

async function main(): Promise<void> {
try {
Expand All @@ -9,6 +10,7 @@ async function main(): Promise<void> {
handlePullRequest();
break;
case "pull_request_review_comment":
handlePullRequestComment();
break;
default:
warning("Skipped: unsupported github event");
Expand Down
2 changes: 2 additions & 0 deletions src/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { Context } from "@actions/github/lib/context";
export const OVERVIEW_MESSAGE_SIGNATURE =
"\n<!-- presubmit.ai: overview message -->";

export const COMMENT_SIGNATURE = "\n<!-- presubmit.ai: comment -->";

export const PAYLOAD_TAG_OPEN = "\n<!-- presubmit.ai: payload --";
export const PAYLOAD_TAG_CLOSE = "\n-- presubmit.ai: payload -->";

Expand Down
75 changes: 74 additions & 1 deletion src/prompts.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { runPrompt } from "./ai";
import { z } from "zod";
import { formatFileDiff, File, FileDiff, generateFileCodeDiff } from "./diff";
import { ReviewCommentThread } from "./comments";

type PullRequestSummaryPrompt = {
prTitle: string;
Expand All @@ -20,7 +21,7 @@ export type PullRequestSummary = {
type: string[];
};

export default async function runSummaryPrompt(
export async function runSummaryPrompt(
pr: PullRequestSummaryPrompt
): Promise<PullRequestSummary> {
let systemPrompt = `You are a helpful assistant that summarizes Git Pull Requests (PRs).`;
Expand Down Expand Up @@ -313,3 +314,75 @@ ${pr.files.map((file) => generateFileCodeDiff(file)).join("\n\n")}
schema,
})) as PullRequestReview;
}

type ReviewCommentPrompt = {
commentThread: ReviewCommentThread;
commentFileDiff: FileDiff;
};

export type ReviewCommentResponse = {
response_comment: string;
action_requested: boolean;
};

export async function runReviewCommentPrompt({
commentThread,
commentFileDiff,
}: ReviewCommentPrompt): Promise<ReviewCommentResponse> {
let systemPrompt = `You are a helpful senior software engineer that reviews comments on Git Pull Requests (PRs). Your task is to provide a response to a comment on a PR review. The comment might be part of a longer comment thread, so make sure to respond to the specific comment and not the whole thread.
The comment thread is specific to a line or multiple lines of code in a specific file. Keep that in mind when writing your response, but do not assume the code is complete or correct. Also, the comment might request you to suggest some changes or improvements outside the code snippet, so judge accordingly.
In your response, return the exact text of your comment, in markdown, starting by mentioning the @user who made the comment. Your response will be used as a comment on the PR, so make sure it's easy to understand and actionable.
Comments from @presubmit are yours.
IMPORTANT: Do not respond with generic comments like "Thanks for the PR!" or "LGTM" or "Let me know if you need any help". If the input comment is not actionable, return an empty string. Do not offer to help unless asked.
`;

const startLine =
commentThread.comments[0].start_line || commentThread.comments[0].line;
const endLine = commentThread.comments[0].line;

let userPrompt = `
Below you'll see the full comment thread, but you should focus specifically on the last comment.
<Comment Thread>
${commentThread.comments
.map(
(comment) =>
`<author>@${comment.user.login}</author>\n<comment>${comment.body}</comment>`
)
.join("\n")}
</Comment Thread>
<Comment Scope>
<Lines>${startLine} - ${endLine}</Lines>
<Hunk>
${commentThread.comments[0].diff_hunk}
</Hunk>
</Comment Scope>
<Comment File Diff>
${generateFileCodeDiff(commentFileDiff)}
</Comment File Diff>
`;

const schema = z.object({
response_comment: z
.string()
.describe(
"Your response to the comment in markdown format, starting by mentioning the user"
),
action_requested: z
.boolean()
.describe(
"True if the input comment required an action from you. False otherwise."
),
});

return (await runPrompt({
prompt: userPrompt,
systemPrompt,
schema,
})) as ReviewCommentResponse;
}
Loading

0 comments on commit ba50bfd

Please sign in to comment.