diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..ad93c14a --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,5 @@ +{ + "image": "mcr.microsoft.com/devcontainers/universal:2", + "features": { + } +} diff --git a/README.md b/README.md index d9d64925..5e1c2759 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,26 @@ -# OpenAI ChatGPT-based PR reviewer and summarizer +# AI-based PR reviewer and summarizer -![AI](./docs/images/ai.png) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) + +
+ + GitHub + +
## Overview -This [OpenAI ChatGPT-based](https://platform.openai.com/docs/guides/chat) GitHub -Action provides a summary, release notes and review of pull requests. The unique -features of this action are: +CodeRabbit `ai-pr-reviewer` is an AI-based code reviewer and summarizer for +GitHub pull requests using OpenAI's `gpt-3.5-turbo` and `gpt-4` models. It is +designed to be used as a GitHub Action and can be configured to run on every +pull request and review comments + +## Reviewer Features: -- **Line-by-line code change suggestions**: This action reviews the changes line - by line and provides code change suggestions that can be directly committed - from the GitHub UI. +- **PR Summarization**: It generates a summary and release notes of the changes + in the pull request. +- **Line-by-line code change suggestions**: Reviews the changes line by line and + provides code change suggestions. - **Continuous, incremental reviews**: Reviews are performed on each commit within a pull request, rather than a one-time review on the entire pull request. @@ -32,17 +42,32 @@ features of this action are: `summarize_release_notes` prompts to focus on specific aspects of the review process or even change the review objective. -# Professional Version of CodeRabbit
+To use this tool, you need to add the provided YAML file to your repository and +configure the required environment variables, such as `GITHUB_TOKEN` and +`OPENAI_API_KEY`. For more information on usage, examples, contributing, and +FAQs, you can refer to the sections below. + +- [Overview](#overview) +- [Professional Version of CodeRabbit](#professional-version-of-coderabbit) +- [Reviewer Features](#reviewer-features) +- [Install instructions](#install-instructions) +- [Conversation with CodeRabbit](#conversation-with-coderabbit) +- [Examples](#examples) +- [Contribute](#contribute) +- [FAQs](#faqs) + +## CodeRabbit Pro -The professional version of our openai-pr-reviewer project is now live at -[coderabbit.ai](http://Coderabbit.ai). Building upon our open-source foundation, -CodeRabbit offers improved features, dedicated support, and our ongoing -commitment to superior code reviews. +The professional version of `openai-pr-reviewer` project is now available at +[coderabbit.ai](http://coderabbit.ai). Building upon our open source foundation, +CodeRabbit Pro offers premium features including enhanced context and superior +noise reduction, dedicated support, and our ongoing commitment to improve code +reviews. Moreover, CodeRabbit Pro is free for open source projects. -## Usage +## Install instructions -Add the below file to your repository at -`.github/workflows/openai-pr-reviewer.yml` +`ai-pr-reviewer` runs as a GitHub Action. Add the below file to your repository +at `.github/workflows/ai-pr-reviewer.yml` ```yaml name: Code Review @@ -67,7 +92,7 @@ jobs: review: runs-on: ubuntu-latest steps: - - uses: coderabbitai/openai-pr-reviewer@latest + - uses: coderabbitai/ai-pr-reviewer@latest env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} @@ -77,39 +102,6 @@ jobs: review_comment_lgtm: false ``` -### Conversation with OpenAI - -You can reply to a review comment made by this action and get a response based -on the diff context. Additionally, you can invite the bot to a conversation by -tagging it in the comment (`@openai`). - -Example: - -> @openai Please generate a test plan for this file. - -Note: A review comment is a comment made on a diff or a file in the pull -request. - -### Ignoring PRs - -Sometimes it is useful to ignore a PR. For example, if you are using this action -to review documentation, you can ignore PRs that only change the documentation. -To ignore a PR, add the following keyword in the PR description: - -```text -@openai: ignore -``` - -### Screenshots - -![PR Summary](./docs/images/openai-pr-summary.png) - -![PR Release Notes](./docs/images/openai-pr-release-notes.png) - -![PR Review](./docs/images/openai-pr-review.png) - -![PR Conversation](./docs/images/openai-review-conversation.png) - #### Environment variables - `GITHUB_TOKEN`: This should already be available to the GitHub Action @@ -123,7 +115,7 @@ To ignore a PR, add the following keyword in the PR description: ### Models: `gpt-4` and `gpt-3.5-turbo` -At FluxNinja, we use `gpt-3.5-turbo` for lighter tasks such as summarizing the +Recommend using `gpt-3.5-turbo` for lighter tasks such as summarizing the changes (`openai_light_model` in configuration) and `gpt-4` for more complex review and commenting tasks (`openai_heavy_model` in configuration). @@ -143,20 +135,14 @@ value. For example, to review docs/blog posts, you can use the following prompt: ```yaml system_message: | - You are `@openai` (aka `github-actions[bot]`), a language model + You are `@coderabbitai` (aka `github-actions[bot]`), a language model trained by OpenAI. Your purpose is to act as a highly experienced DevRel (developer relations) professional with focus on cloud-native infrastructure. Company context - - FluxNinja is a cloud-native intelligent load management platform. - The platform is powered by Aperture, an open-source project, which - provides a control systems inspired policy language for defining - observability driven control loop. FluxNinja's load management, - such as prioritized load shedding and load-based autoscaling, - ensures system stability. FluxNinja ARC, the commercial solution, - offers advanced analytics, intelligent alerting, and policy - visualization. + CodeRabbit is an AI-powered Code reviewer.It boosts code quality and cuts manual effort. Offers context-aware, line-by-line feedback, highlights critical changes, + enables bot interaction, and lets you commit suggestions directly from GitHub. When reviewing or generating content focus on key areas such as - - Accuracy @@ -176,13 +162,50 @@ system_message: | +## Conversation with CodeRabbit + +You can reply to a review comment made by this action and get a response based +on the diff context. Additionally, you can invite the bot to a conversation by +tagging it in the comment (`@coderabbitai`). + +Example: + +> @coderabbitai Please generate a test plan for this file. + +Note: A review comment is a comment made on a diff or a file in the pull +request. + +### Ignoring PRs + +Sometimes it is useful to ignore a PR. For example, if you are using this action +to review documentation, you can ignore PRs that only change the documentation. +To ignore a PR, add the following keyword in the PR description: + +```text +@coderabbitai: ignore +``` + +## Examples + +Some of the reviews done by ai-pr-reviewer + +![PR Summary](./docs/images/PRSummary.png) + +![PR Release Notes](./docs/images/ReleaseNotes.png) + +![PR Review](./docs/images/section-1.png) + +![PR Conversation](./docs/images/section-3.png) + Any suggestions or pull requests for improving the prompts are highly appreciated. -## Developing +## Contribute + +### Developing > First, you'll need to have a reasonably modern version of `node` handy, tested -> with node 16. +> with node 17+. Install the dependencies @@ -229,7 +252,7 @@ jobs: review: runs-on: ubuntu-latest steps: - - uses: coderabbitai/openai-pr-reviewer@latest + - uses: coderabbitai/ai-pr-reviewer@latest env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} diff --git a/action.yml b/action.yml index 33307a54..c2fc4d76 100644 --- a/action.yml +++ b/action.yml @@ -1,9 +1,9 @@ -name: 'OpenAI ChatGPT based PR Reviewer & Summarizer' -description: 'OpenAI ChatGPT based PR Reviewer and Summarizer' +name: 'AI-based PR Reviewer & Summarizer with Chat Capabilities' +description: 'AI-based PR Reviewer & Summarizer with Chat Capabilities' branding: - icon: 'aperture' + icon: 'git-merge' color: 'orange' -author: 'FluxNinja, Inc.' +author: 'CodeRabbit LLC' inputs: debug: required: false @@ -33,8 +33,74 @@ inputs: - https://github.com/isaacs/minimatch default: | !dist/** + !**/*.app + !**/*.bin + !**/*.bz2 + !**/*.class + !**/*.db + !**/*.csv + !**/*.tsv + !**/*.dat + !**/*.dll + !**/*.dylib + !**/*.egg + !**/*.glif + !**/*.gz + !**/*.xz + !**/*.zip + !**/*.7z + !**/*.rar + !**/*.zst + !**/*.ico + !**/*.jar + !**/*.tar + !**/*.war + !**/*.lo + !**/*.log + !**/*.mp3 + !**/*.wav + !**/*.wma + !**/*.mp4 + !**/*.avi + !**/*.mkv + !**/*.wmv + !**/*.m4a + !**/*.m4v + !**/*.3gp + !**/*.3g2 + !**/*.rm + !**/*.mov + !**/*.flv + !**/*.iso + !**/*.swf + !**/*.flac + !**/*.nar + !**/*.o + !**/*.ogg + !**/*.otf + !**/*.p + !**/*.pdf + !**/*.doc + !**/*.docx + !**/*.xls + !**/*.xlsx + !**/*.ppt + !**/*.pptx + !**/*.pkl + !**/*.pickle + !**/*.pyc + !**/*.pyd + !**/*.pyo + !**/*.pub + !**/*.pem + !**/*.rkt + !**/*.so + !**/*.ss + !**/*.eot + !**/*.exe !**/*.pb.go !**/*.lock + !**/*.ttf !**/*.yaml !**/*.yml !**/*.cfg @@ -46,14 +112,30 @@ inputs: !**/*.json !**/*.mmd !**/*.svg + !**/*.jpeg + !**/*.jpg !**/*.png + !**/*.gif + !**/*.bmp + !**/*.tiff + !**/*.webm + !**/*.woff + !**/*.woff2 !**/*.dot !**/*.md5sum !**/*.wasm + !**/*.snap + !**/*.parquet !**/gen/** !**/_gen/** !**/generated/** + !**/@generated/** !**/vendor/** + !**/*.min.js + !**/*.min.js.map + !**/*.min.js.css + !**/*.tfstate + !**/*.tfstate.backup disable_review: required: false description: 'Only provide the summary and skip the code review.' @@ -74,11 +156,11 @@ inputs: openai_heavy_model: required: false description: 'Model to use for complex tasks such as code reviews.' - default: 'gpt-3.5-turbo' + default: 'gpt-4' openai_model_temperature: required: false description: 'Temperature for GPT model' - default: '0.0' + default: '0.05' openai_retries: required: false description: @@ -91,12 +173,16 @@ inputs: openai_concurrency_limit: required: false description: 'How many concurrent API calls to make to OpenAI servers?' - default: '4' + default: '6' + github_concurrency_limit: + required: false + description: 'How many concurrent API calls to make to GitHub?' + default: '6' system_message: required: false description: 'System message to be sent to OpenAI' default: | - You are `@openai` (aka `github-actions[bot]`), a language model + You are `@coderabbitai` (aka `github-actions[bot]`), a language model trained by OpenAI. Your purpose is to act as a highly experienced software engineer and provide a thorough review of the code hunks and suggest code snippets to improve key areas such as: @@ -110,54 +196,44 @@ inputs: - Modularity - Complexity - Optimization + - Best practices: DRY, SOLID, KISS - Refrain from commenting on minor code style issues, missing - comments/documentation, or giving compliments, unless explicitly - requested. Concentrate on identifying and resolving significant + Do not comment on minor code style issues, missing + comments/documentation. Identify and resolve significant concerns to improve overall code quality while deliberately disregarding minor issues. - - Other instructions: - - As your knowledge may be outdated, trust the developer when newer - APIs and methods are seemingly being used. - - Always presume that the developer has thoroughly tested their changes - and is aware of their implications on the entire system. Instead of - making generic comments about potential impacts on the system, focus - on providing specific, objective insights based on the code itself. - - Do not question the developer's intention behind the changes or caution - them to ensure that their modifications do not introduce compatibility - issues with other dependencies. - - Never ask the developer to review the changes. summarize: required: false description: 'The prompt for final summarization response' default: | - Provide your final response in the `markdown` format with - the following content: - - High-level summary (comment on the overall change instead of - specific files within 80 words) - - Table of files and their summaries. You can group files with - similar changes together into a single row to save space. + Provide your final response in markdown with the following content: + + - **Walkthrough**: A high-level summary of the overall change instead of + specific files within 80 words. + - **Changes**: A markdown table of files and their summaries. Group files + with similar changes together into a single row to save space. + - **Poem**: Below the changes, include a whimsical, short poem written by + a rabbit to celebrate the changes. Format the poem as a quote using + the ">" symbol and feel free to use emojis where relevant. + + Avoid additional commentary as this summary will be added as a comment on the + GitHub pull request. Use the titles "Walkthrough" and "Changes" and they must be H2. - Avoid additional commentary as this summary will be added as a - comment on the GitHub pull request. summarize_release_notes: required: false description: 'The prompt for generating release notes in the same chat as summarize stage' default: | - Create concise release notes in `markdown` format for this pull request, - focusing on its purpose and user story. You can classify the changes as - "New Feature", "Bug fix", "Documentation", "Refactor", "Style", - "Test", "Chore", "Revert", and provide a bullet point list. For example: - "New Feature: An integrations page was added to the UI". Keep your - response within 50-100 words. Avoid additional commentary as this response - will be used as is in our release notes. - - Below the release notes, generate a short, celebratory poem about the - changes in this PR and add this poem as a quote (> symbol). You can - use emojis in the poem, where they are relevant. + Craft concise release notes for the pull request. + Focus on the purpose and user impact, categorizing changes as "New Feature", "Bug Fix", + "Documentation", "Refactor", "Style", "Test", "Chore", or "Revert". Provide a bullet-point list, + e.g., "- New Feature: Added search functionality to the UI". Limit your response to 50-100 words + and emphasize features visible to the end-user while omitting code-level details. + language: + required: false + description: ISO code for the response language + default: en-US runs: using: 'node16' main: 'dist/index.js' diff --git a/dist/index.js b/dist/index.js index 7dff9703..a6f80920 100644 --- a/dist/index.js +++ b/dist/index.js @@ -3792,7 +3792,10 @@ class Bot { const currentDate = new Date().toISOString().split('T')[0]; const systemMessage = `${options.systemMessage} Knowledge cutoff: ${openaiOptions.tokenLimits.knowledgeCutOff} -Current date: ${currentDate}`; +Current date: ${currentDate} + +IMPORTANT: Entire response must be in the language with ISO code: ${options.language} +`; this.api = new ChatGPTAPI({ apiBaseUrl: options.apiBaseUrl, systemMessage, @@ -3895,7 +3898,7 @@ Current date: ${currentDate}`; /* harmony export */ "oi": () => (/* binding */ RAW_SUMMARY_START_TAG), /* harmony export */ "rV": () => (/* binding */ RAW_SUMMARY_END_TAG) /* harmony export */ }); -/* unused harmony exports COMMENT_GREETING, DESCRIPTION_START_TAG, DESCRIPTION_END_TAG, COMMIT_ID_START_TAG, COMMIT_ID_END_TAG */ +/* unused harmony exports COMMENT_GREETING, IN_PROGRESS_START_TAG, IN_PROGRESS_END_TAG, DESCRIPTION_START_TAG, DESCRIPTION_END_TAG, COMMIT_ID_START_TAG, COMMIT_ID_END_TAG */ /* harmony import */ var _actions_core__WEBPACK_IMPORTED_MODULE_0__ = __nccwpck_require__(2186); /* harmony import */ var _actions_core__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__nccwpck_require__.n(_actions_core__WEBPACK_IMPORTED_MODULE_0__); /* harmony import */ var _actions_github__WEBPACK_IMPORTED_MODULE_1__ = __nccwpck_require__(5438); @@ -3908,23 +3911,24 @@ Current date: ${currentDate}`; // eslint-disable-next-line camelcase const context = _actions_github__WEBPACK_IMPORTED_MODULE_1__.context; const repo = context.repo; -const COMMENT_GREETING = ':robot: OpenAI'; -const COMMENT_TAG = ''; -const COMMENT_REPLY_TAG = ''; -const SUMMARIZE_TAG = ''; -const DESCRIPTION_START_TAG = ` -`; -const DESCRIPTION_END_TAG = ''; -const RAW_SUMMARY_START_TAG = ` +const COMMENT_GREETING = `Image description CodeRabbit`; +const COMMENT_TAG = ''; +const COMMENT_REPLY_TAG = ''; +const SUMMARIZE_TAG = ''; +const IN_PROGRESS_START_TAG = ''; +const IN_PROGRESS_END_TAG = ''; +const DESCRIPTION_START_TAG = ''; +const DESCRIPTION_END_TAG = ''; +const RAW_SUMMARY_START_TAG = ` -`; -const SHORT_SUMMARY_START_TAG = ` +`; +const SHORT_SUMMARY_START_TAG = ` -`; +`; const COMMIT_ID_START_TAG = ''; const COMMIT_ID_END_TAG = ''; class Commenter { @@ -3972,7 +3976,7 @@ ${tag}`; } removeContentWithinTags(content, startTag, endTag) { const start = content.indexOf(startTag); - const end = content.indexOf(endTag); + const end = content.lastIndexOf(endTag); if (start >= 0 && end >= 0) { return content.slice(0, start) + content.slice(end + endTag.length); } @@ -4008,7 +4012,7 @@ ${tag}`; } const description = this.getDescription(body); const messageClean = this.removeContentWithinTags(message, DESCRIPTION_START_TAG, DESCRIPTION_END_TAG); - const newDescription = `${description}${DESCRIPTION_START_TAG}\n${messageClean}\n${DESCRIPTION_END_TAG}`; + const newDescription = `${description}\n${DESCRIPTION_START_TAG}\n${messageClean}\n${DESCRIPTION_END_TAG}`; await _octokit__WEBPACK_IMPORTED_MODULE_2__/* .octokit.pulls.update */ .K.pulls.update({ owner: repo.owner, repo: repo.repo, @@ -4035,33 +4039,122 @@ ${COMMENT_TAG}`; message }); } - async submitReview(pullNumber, commitId) { - (0,_actions_core__WEBPACK_IMPORTED_MODULE_0__.info)(`Submitting review for PR #${pullNumber}, total comments: ${this.reviewCommentsBuffer.length}`); - let commentCounter = 0; + async deletePendingReview(pullNumber) { + try { + const reviews = await _octokit__WEBPACK_IMPORTED_MODULE_2__/* .octokit.pulls.listReviews */ .K.pulls.listReviews({ + owner: repo.owner, + repo: repo.repo, + // eslint-disable-next-line camelcase + pull_number: pullNumber + }); + const pendingReview = reviews.data.find(review => review.state === 'PENDING'); + if (pendingReview) { + (0,_actions_core__WEBPACK_IMPORTED_MODULE_0__.info)(`Deleting pending review for PR #${pullNumber} id: ${pendingReview.id}`); + try { + await _octokit__WEBPACK_IMPORTED_MODULE_2__/* .octokit.pulls.deletePendingReview */ .K.pulls.deletePendingReview({ + owner: repo.owner, + repo: repo.repo, + // eslint-disable-next-line camelcase + pull_number: pullNumber, + // eslint-disable-next-line camelcase + review_id: pendingReview.id + }); + } + catch (e) { + (0,_actions_core__WEBPACK_IMPORTED_MODULE_0__.warning)(`Failed to delete pending review: ${e}`); + } + } + } + catch (e) { + (0,_actions_core__WEBPACK_IMPORTED_MODULE_0__.warning)(`Failed to list reviews: ${e}`); + } + } + async submitReview(pullNumber, commitId, statusMsg) { + const body = `${COMMENT_GREETING} + +${statusMsg} +`; + if (this.reviewCommentsBuffer.length === 0) { + // Submit empty review with statusMsg + (0,_actions_core__WEBPACK_IMPORTED_MODULE_0__.info)(`Submitting empty review for PR #${pullNumber}`); + try { + await _octokit__WEBPACK_IMPORTED_MODULE_2__/* .octokit.pulls.createReview */ .K.pulls.createReview({ + owner: repo.owner, + repo: repo.repo, + // eslint-disable-next-line camelcase + pull_number: pullNumber, + // eslint-disable-next-line camelcase + commit_id: commitId, + event: 'COMMENT', + body + }); + } + catch (e) { + (0,_actions_core__WEBPACK_IMPORTED_MODULE_0__.warning)(`Failed to submit empty review: ${e}`); + } + return; + } for (const comment of this.reviewCommentsBuffer) { - (0,_actions_core__WEBPACK_IMPORTED_MODULE_0__.info)(`Posting comment: ${comment.message}`); - let found = false; const comments = await this.getCommentsAtRange(pullNumber, comment.path, comment.startLine, comment.endLine); for (const c of comments) { if (c.body.includes(COMMENT_TAG)) { - (0,_actions_core__WEBPACK_IMPORTED_MODULE_0__.info)(`Updating review comment for ${comment.path}:${comment.startLine}-${comment.endLine}: ${comment.message}`); + (0,_actions_core__WEBPACK_IMPORTED_MODULE_0__.info)(`Deleting review comment for ${comment.path}:${comment.startLine}-${comment.endLine}: ${comment.message}`); try { - await _octokit__WEBPACK_IMPORTED_MODULE_2__/* .octokit.pulls.updateReviewComment */ .K.pulls.updateReviewComment({ + await _octokit__WEBPACK_IMPORTED_MODULE_2__/* .octokit.pulls.deleteReviewComment */ .K.pulls.deleteReviewComment({ owner: repo.owner, repo: repo.repo, // eslint-disable-next-line camelcase - comment_id: c.id, - body: comment.message + comment_id: c.id }); } catch (e) { - (0,_actions_core__WEBPACK_IMPORTED_MODULE_0__.warning)(`Failed to update review comment: ${e}`); + (0,_actions_core__WEBPACK_IMPORTED_MODULE_0__.warning)(`Failed to delete review comment: ${e}`); } - found = true; - break; } } - if (!found) { + } + await this.deletePendingReview(pullNumber); + const generateCommentData = (comment) => { + const commentData = { + path: comment.path, + body: comment.message, + line: comment.endLine + }; + if (comment.startLine !== comment.endLine) { + // eslint-disable-next-line camelcase + commentData.start_line = comment.startLine; + // eslint-disable-next-line camelcase + commentData.start_side = 'RIGHT'; + } + return commentData; + }; + try { + const review = await _octokit__WEBPACK_IMPORTED_MODULE_2__/* .octokit.pulls.createReview */ .K.pulls.createReview({ + owner: repo.owner, + repo: repo.repo, + // eslint-disable-next-line camelcase + pull_number: pullNumber, + // eslint-disable-next-line camelcase + commit_id: commitId, + comments: this.reviewCommentsBuffer.map(comment => generateCommentData(comment)) + }); + (0,_actions_core__WEBPACK_IMPORTED_MODULE_0__.info)(`Submitting review for PR #${pullNumber}, total comments: ${this.reviewCommentsBuffer.length}, review id: ${review.data.id}`); + await _octokit__WEBPACK_IMPORTED_MODULE_2__/* .octokit.pulls.submitReview */ .K.pulls.submitReview({ + owner: repo.owner, + repo: repo.repo, + // eslint-disable-next-line camelcase + pull_number: pullNumber, + // eslint-disable-next-line camelcase + review_id: review.data.id, + event: 'COMMENT', + body + }); + } + catch (e) { + (0,_actions_core__WEBPACK_IMPORTED_MODULE_0__.warning)(`Failed to create review: ${e}. Falling back to individual comments.`); + await this.deletePendingReview(pullNumber); + let commentCounter = 0; + for (const comment of this.reviewCommentsBuffer) { (0,_actions_core__WEBPACK_IMPORTED_MODULE_0__.info)(`Creating new review comment for ${comment.path}:${comment.startLine}-${comment.endLine}: ${comment.message}`); const commentData = { owner: repo.owner, @@ -4070,25 +4163,17 @@ ${COMMENT_TAG}`; pull_number: pullNumber, // eslint-disable-next-line camelcase commit_id: commitId, - body: comment.message, - path: comment.path, - line: comment.endLine + ...generateCommentData(comment) }; - if (comment.startLine !== comment.endLine) { - // eslint-disable-next-line camelcase - commentData.start_side = 'RIGHT'; - // eslint-disable-next-line camelcase - commentData.start_line = comment.startLine; - } try { await _octokit__WEBPACK_IMPORTED_MODULE_2__/* .octokit.pulls.createReviewComment */ .K.pulls.createReviewComment(commentData); } - catch (e) { - (0,_actions_core__WEBPACK_IMPORTED_MODULE_0__.warning)(`Failed to create review comment: ${e}`); + catch (ee) { + (0,_actions_core__WEBPACK_IMPORTED_MODULE_0__.warning)(`Failed to create review comment: ${ee}`); } + commentCounter++; + (0,_actions_core__WEBPACK_IMPORTED_MODULE_0__.info)(`Comment ${commentCounter}/${this.reviewCommentsBuffer.length} posted`); } - commentCounter++; - (0,_actions_core__WEBPACK_IMPORTED_MODULE_0__.info)(`Comment ${commentCounter}/${this.reviewCommentsBuffer.length} posted`); } } async reviewCommentReply(pullNumber, topLevelComment, message) { @@ -4255,14 +4340,21 @@ ${chain} } async create(body, target) { try { - // get commend ID from the response - await _octokit__WEBPACK_IMPORTED_MODULE_2__/* .octokit.issues.createComment */ .K.issues.createComment({ + // get comment ID from the response + const response = await _octokit__WEBPACK_IMPORTED_MODULE_2__/* .octokit.issues.createComment */ .K.issues.createComment({ owner: repo.owner, repo: repo.repo, // eslint-disable-next-line camelcase issue_number: target, body }); + // add comment to issueCommentsCache + if (this.issueCommentsCache[target]) { + this.issueCommentsCache[target].push(response.data); + } + else { + this.issueCommentsCache[target] = [response.data]; + } } catch (e) { (0,_actions_core__WEBPACK_IMPORTED_MODULE_0__.warning)(`Failed to create comment: ${e}`); @@ -4402,6 +4494,39 @@ ${chain} } return allCommits; } + // add in-progress status to the comment body + addInProgressStatus(commentBody, statusMsg) { + const start = commentBody.indexOf(IN_PROGRESS_START_TAG); + const end = commentBody.indexOf(IN_PROGRESS_END_TAG); + // add to the beginning of the comment body if the marker doesn't exist + // otherwise do nothing + if (start === -1 || end === -1) { + return `${IN_PROGRESS_START_TAG} + +Currently reviewing new changes in this PR... + +${statusMsg} + +${IN_PROGRESS_END_TAG} + +--- + +${commentBody}`; + } + return commentBody; + } + // remove in-progress status from the comment body + removeInProgressStatus(commentBody) { + const start = commentBody.indexOf(IN_PROGRESS_START_TAG); + const end = commentBody.indexOf(IN_PROGRESS_END_TAG); + // remove the in-progress status if the marker exists + // otherwise do nothing + if (start !== -1 && end !== -1) { + return (commentBody.substring(0, start) + + commentBody.substring(end + IN_PROGRESS_END_TAG.length)); + } + return commentBody; + } } @@ -4511,7 +4636,7 @@ __nccwpck_require__.r(__webpack_exports__); async function run() { - const options = new _options__WEBPACK_IMPORTED_MODULE_2__/* .Options */ .Ei((0,_actions_core__WEBPACK_IMPORTED_MODULE_0__.getBooleanInput)('debug'), (0,_actions_core__WEBPACK_IMPORTED_MODULE_0__.getBooleanInput)('disable_review'), (0,_actions_core__WEBPACK_IMPORTED_MODULE_0__.getBooleanInput)('disable_release_notes'), (0,_actions_core__WEBPACK_IMPORTED_MODULE_0__.getInput)('max_files'), (0,_actions_core__WEBPACK_IMPORTED_MODULE_0__.getBooleanInput)('review_simple_changes'), (0,_actions_core__WEBPACK_IMPORTED_MODULE_0__.getBooleanInput)('review_comment_lgtm'), (0,_actions_core__WEBPACK_IMPORTED_MODULE_0__.getMultilineInput)('path_filters'), (0,_actions_core__WEBPACK_IMPORTED_MODULE_0__.getInput)('system_message'), (0,_actions_core__WEBPACK_IMPORTED_MODULE_0__.getInput)('openai_light_model'), (0,_actions_core__WEBPACK_IMPORTED_MODULE_0__.getInput)('openai_heavy_model'), (0,_actions_core__WEBPACK_IMPORTED_MODULE_0__.getInput)('openai_model_temperature'), (0,_actions_core__WEBPACK_IMPORTED_MODULE_0__.getInput)('openai_retries'), (0,_actions_core__WEBPACK_IMPORTED_MODULE_0__.getInput)('openai_timeout_ms'), (0,_actions_core__WEBPACK_IMPORTED_MODULE_0__.getInput)('openai_concurrency_limit'), (0,_actions_core__WEBPACK_IMPORTED_MODULE_0__.getInput)('openai_base_url')); + const options = new _options__WEBPACK_IMPORTED_MODULE_2__/* .Options */ .Ei((0,_actions_core__WEBPACK_IMPORTED_MODULE_0__.getBooleanInput)('debug'), (0,_actions_core__WEBPACK_IMPORTED_MODULE_0__.getBooleanInput)('disable_review'), (0,_actions_core__WEBPACK_IMPORTED_MODULE_0__.getBooleanInput)('disable_release_notes'), (0,_actions_core__WEBPACK_IMPORTED_MODULE_0__.getInput)('max_files'), (0,_actions_core__WEBPACK_IMPORTED_MODULE_0__.getBooleanInput)('review_simple_changes'), (0,_actions_core__WEBPACK_IMPORTED_MODULE_0__.getBooleanInput)('review_comment_lgtm'), (0,_actions_core__WEBPACK_IMPORTED_MODULE_0__.getMultilineInput)('path_filters'), (0,_actions_core__WEBPACK_IMPORTED_MODULE_0__.getInput)('system_message'), (0,_actions_core__WEBPACK_IMPORTED_MODULE_0__.getInput)('openai_light_model'), (0,_actions_core__WEBPACK_IMPORTED_MODULE_0__.getInput)('openai_heavy_model'), (0,_actions_core__WEBPACK_IMPORTED_MODULE_0__.getInput)('openai_model_temperature'), (0,_actions_core__WEBPACK_IMPORTED_MODULE_0__.getInput)('openai_retries'), (0,_actions_core__WEBPACK_IMPORTED_MODULE_0__.getInput)('openai_timeout_ms'), (0,_actions_core__WEBPACK_IMPORTED_MODULE_0__.getInput)('openai_concurrency_limit'), (0,_actions_core__WEBPACK_IMPORTED_MODULE_0__.getInput)('github_concurrency_limit'), (0,_actions_core__WEBPACK_IMPORTED_MODULE_0__.getInput)('openai_base_url'), (0,_actions_core__WEBPACK_IMPORTED_MODULE_0__.getInput)('language')); // print options options.print(); const prompts = new _prompts__WEBPACK_IMPORTED_MODULE_5__/* .Prompts */ .j((0,_actions_core__WEBPACK_IMPORTED_MODULE_0__.getInput)('summarize'), (0,_actions_core__WEBPACK_IMPORTED_MODULE_0__.getInput)('summarize_release_notes')); @@ -4596,16 +4721,20 @@ const octokit = new RetryAndThrottlingOctokit({ Retry after: ${retryAfter} seconds Retry count: ${retryCount} `); - return true; + if (retryCount <= 3) { + (0,_actions_core__WEBPACK_IMPORTED_MODULE_0__.warning)(`Retrying after ${retryAfter} seconds!`); + return true; + } }, - onSecondaryRateLimit: (_retryAfter, options) => { - (0,_actions_core__WEBPACK_IMPORTED_MODULE_0__.warning)(`SecondaryRateLimit detected for request ${options.method} ${options.url}`); + onSecondaryRateLimit: (retryAfter, options) => { + (0,_actions_core__WEBPACK_IMPORTED_MODULE_0__.warning)(`SecondaryRateLimit detected for request ${options.method} ${options.url} ; retry after ${retryAfter} seconds`); + // if we are doing a POST method on /repos/{owner}/{repo}/pulls/{pull_number}/reviews then we shouldn't retry + if (options.method === 'POST' && + options.url.match(/\/repos\/.*\/.*\/pulls\/.*\/reviews/)) { + return false; + } return true; } - }, - retry: { - doNotRetry: ['429'], - maxRetries: 10 } }); @@ -6400,6 +6529,10 @@ class TokenLimits { this.maxTokens = 32600; this.responseTokens = 4000; } + else if (model === 'gpt-3.5-turbo-16k') { + this.maxTokens = 16300; + this.responseTokens = 3000; + } else if (model === 'gpt-4') { this.maxTokens = 8000; this.responseTokens = 2000; @@ -6435,10 +6568,12 @@ class Options { openaiRetries; openaiTimeoutMS; openaiConcurrencyLimit; + githubConcurrencyLimit; lightTokenLimits; heavyTokenLimits; apiBaseUrl; - constructor(debug, disableReview, disableReleaseNotes, maxFiles = '0', reviewSimpleChanges = false, reviewCommentLGTM = false, pathFilters = null, systemMessage = '', openaiLightModel = 'gpt-3.5-turbo', openaiHeavyModel = 'gpt-3.5-turbo', openaiModelTemperature = '0.0', openaiRetries = '3', openaiTimeoutMS = '120000', openaiConcurrencyLimit = '4', apiBaseUrl = 'https://api.openai.com/v1') { + language; + constructor(debug, disableReview, disableReleaseNotes, maxFiles = '0', reviewSimpleChanges = false, reviewCommentLGTM = false, pathFilters = null, systemMessage = '', openaiLightModel = 'gpt-3.5-turbo', openaiHeavyModel = 'gpt-3.5-turbo', openaiModelTemperature = '0.0', openaiRetries = '3', openaiTimeoutMS = '120000', openaiConcurrencyLimit = '6', githubConcurrencyLimit = '6', apiBaseUrl = 'https://api.openai.com/v1', language = 'en-US') { this.debug = debug; this.disableReview = disableReview; this.disableReleaseNotes = disableReleaseNotes; @@ -6453,9 +6588,11 @@ class Options { this.openaiRetries = parseInt(openaiRetries); this.openaiTimeoutMS = parseInt(openaiTimeoutMS); this.openaiConcurrencyLimit = parseInt(openaiConcurrencyLimit); + this.githubConcurrencyLimit = parseInt(githubConcurrencyLimit); this.lightTokenLimits = new TokenLimits(openaiLightModel); this.heavyTokenLimits = new TokenLimits(openaiHeavyModel); this.apiBaseUrl = apiBaseUrl; + this.language = language; } // print all options using core.info print() { @@ -6473,9 +6610,11 @@ class Options { (0,core.info)(`openai_retries: ${this.openaiRetries}`); (0,core.info)(`openai_timeout_ms: ${this.openaiTimeoutMS}`); (0,core.info)(`openai_concurrency_limit: ${this.openaiConcurrencyLimit}`); + (0,core.info)(`github_concurrency_limit: ${this.githubConcurrencyLimit}`); (0,core.info)(`summary_token_limits: ${this.lightTokenLimits.string()}`); (0,core.info)(`review_token_limits: ${this.heavyTokenLimits.string()}`); (0,core.info)(`api_base_url: ${this.apiBaseUrl}`); + (0,core.info)(`language: ${this.language}`); } checkPath(path) { const ok = this.pathFilters.check(path); @@ -6569,7 +6708,11 @@ $file_diff ## Instructions -I would like you to summarize the diff within 50 words. +I would like you to succinctly summarize the diff within 100 words. +If applicable, your summary should include a note about alterations +to the signatures of exported functions, global data structures and +variables, and any changes that might affect the external interface or +behavior of the code. `; triageFileDiff = `Below the summary, I would also like you to triage the diff as \`NEEDS_REVIEW\` or \`APPROVED\` based on the following criteria: @@ -6611,9 +6754,9 @@ $raw_summary \`\`\` `; - summarizeShort = `Your task is to provide a concise summary of the changes -and the goal of this PR. This summary will be used as a prompt while reviewing each -file and must be very clear for the AI bot to understand. + summarizeShort = `Your task is to provide a concise summary of the changes. This +summary will be used as a prompt while reviewing each file and must be very clear for +the AI bot to understand. Instructions: @@ -6621,7 +6764,7 @@ Instructions: - Do not provide any instructions to the bot on how to perform the review. - Do not mention that files need a through review or caution about potential issues. - Do not mention that these changes affect the logic or functionality of the code. -- The summary should not exceed 250 words. +- The summary should not exceed 500 words. `; reviewFileDiff = `## GitHub PR Title @@ -6633,136 +6776,83 @@ Instructions: $description \`\`\` -## Summary generated by the AI bot +## Summary of changes \`\`\` $short_summary \`\`\` -## How to parse the changes - -The format for changes provided below consists of multiple change -sections, each containing a new hunk (annotated with line numbers), -an old hunk, and optionally, existing comment chains. Note that the -old hunk code has been replaced by the new hunk to fulfill the -goal of this PR. The line number annotation on each line in the new -hunk is of the format \`\`. - -### Format for changes - - ---new_hunk--- - \`\`\` - - \`\`\` - - ---old_hunk--- - \`\`\` - - \`\`\` - - ---comment_chains--- - \`\`\` - - \`\`\` - - ---end_change_section--- - ... - -## Response Instructions - -- Your task is to review ONLY the new hunks line by line, ONLY pointing out - substantive issues within line number ranges. Provide the exact line - number range (inclusive) for each issue. Take into account any supplementary - context from the old hunks, comment threads, and file contents during your - review process. Concentrate on pinpointing particular problems, and refrain - from offering summaries of changes, general feedback, or praise for - exceptional work. -- Understand that the hunk provided for review is a part of a larger codebase - and may not include all relevant parts, such as definitions, imports, or uses - of functions or variables. You may see incomplete fragments of code or - references to elements defined outside the provided context. Refrain from - flagging issues about missing definitions, imports, or uses unless there is - strong evidence within the provided context to suggest there might be a problem. -- IMPORTANT: Respond only in the below response format (consisting of review - sections). Each review section must have a line number range and a review - comment for that range. Do not include general feedback or summaries. You - may optionally include a single replacement suggestion snippet and/or - multiple new code snippets in the review comment. Separate review sections - using separators. -- IMPORTANT: Line number ranges for each review section must be within the - range of a specific new hunk. must belong to the same - hunk as the . The line number range is sufficient to map - your comment to the code changes in the GitHub pull request. -- Use Markdown format for review comment text and fenced code blocks for - code snippets. Do not annotate code snippets with line numbers. -- If needed, provide replacement code suggestions to fix the issue by using - fenced code blocks with the \`suggestion\` as the language identifier. The - line number range must map exactly to the range (inclusive) that needs to - be replaced within a new hunk. For instance, if 2 lines of code in a hunk - need to be replaced with 15 lines of code, the line number range must be - those exact 2 lines. If an entire hunk need to be replaced with new code, - then the line number range must be the entire hunk and the new code must - exactly replace all the lines in the hunk. -- Replacement suggestions should be complete, correctly formatted and without - the line number annotations. Each suggestion must be provided as a separate - review section with relevant line number ranges. -- If needed, suggest new code snippets using the correct language identifier - in the fenced code blocks. These snippets may be added to a different file - (e.g. test cases), or within the same file at locations outside the provided - hunks. Multiple new code snippets are allowed within a single review section. -- IMPORTANT: If there are no issues found on a line range, you must only - respond with the text \`LGTM!\` for that line range in the review section. -- Reflect on your comments and line number ranges before sending the final - response to ensure accuracy of line number ranges and replacement snippets. - -### Response format expected - - -: - - --- - -: - - \`\`\`suggestion - - \`\`\` - --- - -: - - \`\`\` - - \`\`\` - --- - ... +## IMPORTANT Instructions + +Input: New hunks annotated with line numbers and old hunks (replaced code). Hunks represent incomplete code fragments. +Additional Context: PR title, description, summaries and comment chains. +Task: Review new hunks for substantive issues using provided context and respond with comments if necessary. +Output: Review comments in markdown with exact line number ranges in new hunks. Start and end line numbers must be within the same hunk. For single-line comments, start=end line number. Must use example response format below. +Use fenced code blocks using the relevant language identifier where applicable. +Don't annotate code snippets with line numbers. Format and indent code correctly. +Do not use \`suggestion\` code blocks. +For fixes, use \`diff\` code blocks, marking changes with \`+\` or \`-\`. The line number range for comments with fix snippets must exactly match the range to replace in the new hunk. + +- Do NOT provide general feedback, summaries, explanations of changes, or praises + for making good additions. +- Focus solely on offering specific, objective insights based on the + given context and refrain from making broad comments about potential impacts on + the system or question intentions behind the changes. + +If there are no issues found on a line range, you MUST respond with the +text \`LGTM!\` for that line range in the review section. ## Example ### Example changes - ---new_hunk--- - 1: def add(x, y): - 2: z = x+y - 3: retrn z - 4: - 5: def multiply(x, y): - 6: return x * y +---new_hunk--- +\`\`\` + z = x / y + return z + +20: def add(x, y): +21: z = x + y +22: retrn z +23: +24: def multiply(x, y): +25: return x * y + +def subtract(x, y): + z = x - y +\`\`\` - ---old_hunk--- - def add(x, y): - return x + y +---old_hunk--- +\`\`\` + z = x / y + return z + +def add(x, y): + return x + y + +def subtract(x, y): + z = x - y +\`\`\` + +---comment_chains--- +\`\`\` +Please review this change. +\`\`\` + +---end_change_section--- ### Example response - 1-3: - There's a typo in the return statement. - \`\`\`suggestion - def add(x, y): - z = x + y - return z - \`\`\` - --- - 5-6: - LGTM! - --- +22-22: +There's a syntax error in the add function. +\`\`\`diff +- retrn z ++ return z +\`\`\` +--- +24-25: +LGTM! +--- ## Changes made to \`$filename\` for your review @@ -6890,7 +6980,7 @@ $comment // eslint-disable-next-line camelcase const context = _actions_github__WEBPACK_IMPORTED_MODULE_1__.context; const repo = context.repo; -const ASK_BOT = '@openai'; +const ASK_BOT = '@coderabbitai'; const handleReviewComment = async (heavyBot, options, prompts) => { const commenter = new _commenter__WEBPACK_IMPORTED_MODULE_2__/* .Commenter */ .Es(); const inputs = new _inputs__WEBPACK_IMPORTED_MODULE_5__/* .Inputs */ .k(); @@ -7186,10 +7276,11 @@ var tokenizer = __nccwpck_require__(652); // eslint-disable-next-line camelcase const context = github.context; const repo = context.repo; -const ignoreKeyword = '@openai: ignore'; +const ignoreKeyword = '@coderabbitai: ignore'; const codeReview = async (lightBot, heavyBot, options, prompts) => { const commenter = new lib_commenter/* Commenter */.Es(); const openaiConcurrencyLimit = pLimit(options.openaiConcurrencyLimit); + const githubConcurrencyLimit = pLimit(options.githubConcurrencyLimit); if (context.eventName !== 'pull_request' && context.eventName !== 'pull_request_target') { (0,core.warning)(`Skipped: current event is ${context.eventName}, only support pull_request event`); @@ -7214,10 +7305,12 @@ const codeReview = async (lightBot, heavyBot, options, prompts) => { // get SUMMARIZE_TAG message const existingSummarizeCmt = await commenter.findCommentWithTag(lib_commenter/* SUMMARIZE_TAG */.Rp, context.payload.pull_request.number); let existingCommitIdsBlock = ''; + let existingSummarizeCmtBody = ''; if (existingSummarizeCmt != null) { - inputs.rawSummary = commenter.getRawSummary(existingSummarizeCmt.body); - inputs.shortSummary = commenter.getShortSummary(existingSummarizeCmt.body); - existingCommitIdsBlock = commenter.getReviewedCommitIdsBlock(existingSummarizeCmt.body); + existingSummarizeCmtBody = existingSummarizeCmt.body; + inputs.rawSummary = commenter.getRawSummary(existingSummarizeCmtBody); + inputs.shortSummary = commenter.getShortSummary(existingSummarizeCmtBody); + existingCommitIdsBlock = commenter.getReviewedCommitIdsBlock(existingSummarizeCmtBody); } const allCommitIds = await commenter.getAllCommitIds(); // find highest reviewed commit id @@ -7259,11 +7352,6 @@ const codeReview = async (lightBot, heavyBot, options, prompts) => { (0,core.warning)('Skipped: files is null'); return; } - const commits = incrementalDiff.data.commits; - if (commits.length === 0) { - (0,core.warning)('Skipped: ommits is null'); - return; - } // skip files if they are filtered out const filterSelectedFiles = []; const filterIgnoredFiles = []; @@ -7276,8 +7364,17 @@ const codeReview = async (lightBot, heavyBot, options, prompts) => { filterSelectedFiles.push(file); } } + if (filterSelectedFiles.length === 0) { + (0,core.warning)('Skipped: filterSelectedFiles is null'); + return; + } + const commits = incrementalDiff.data.commits; + if (commits.length === 0) { + (0,core.warning)('Skipped: commits is null'); + return; + } // find hunks to review - const filteredFiles = await Promise.all(filterSelectedFiles.map(async (file) => { + const filteredFiles = await Promise.all(filterSelectedFiles.map(file => githubConcurrencyLimit(async () => { // retrieve file contents let fileContent = ''; if (context.payload.pull_request == null) { @@ -7340,13 +7437,43 @@ ${hunks.oldHunk} else { return null; } - })); + }))); // Filter out any null results const filesAndChanges = filteredFiles.filter(file => file !== null); if (filesAndChanges.length === 0) { (0,core.error)('Skipped: no files to review'); return; } + let statusMsg = `
+Commits +Files that changed from the base of the PR and between ${highestReviewedCommitId} and ${context.payload.pull_request.head.sha} commits. +
+${filesAndChanges.length > 0 + ? ` +
+Files selected (${filesAndChanges.length}) + +* ${filesAndChanges + .map(([filename, , , patches]) => `${filename} (${patches.length})`) + .join('\n* ')} +
+` + : ''} +${filterIgnoredFiles.length > 0 + ? ` +
+Files ignored due to filter (${filterIgnoredFiles.length}) + +* ${filterIgnoredFiles.map(file => file.filename).join('\n* ')} + +
+` + : ''} +`; + // update the existing comment with in progress status + const inProgressSummarizeCmt = commenter.addInProgressStatus(existingSummarizeCmtBody, statusMsg); + // add in progress status to the summarize comment + await commenter.comment(`${inProgressSummarizeCmt}`, lib_commenter/* SUMMARIZE_TAG */.Rp, 'replace'); const summariesFailed = []; const doSummary = async (filename, fileContent, fileDiff) => { (0,core.info)(`summarize: ${filename}`); @@ -7357,19 +7484,18 @@ ${hunks.oldHunk} return null; } ins.filename = filename; + ins.fileDiff = fileDiff; // render prompt based on inputs so far - let tokens = (0,tokenizer/* getTokenCount */.V)(prompts.renderSummarizeFileDiff(ins, options.reviewSimpleChanges)); - const diffTokens = (0,tokenizer/* getTokenCount */.V)(fileDiff); - if (tokens + diffTokens > options.lightTokenLimits.requestTokens) { + const summarizePrompt = prompts.renderSummarizeFileDiff(ins, options.reviewSimpleChanges); + const tokens = (0,tokenizer/* getTokenCount */.V)(summarizePrompt); + if (tokens > options.lightTokenLimits.requestTokens) { (0,core.info)(`summarize: diff tokens exceeds limit, skip ${filename}`); summariesFailed.push(`${filename} (diff tokens exceeds limit)`); return null; } - ins.fileDiff = fileDiff; - tokens += fileDiff.length; // summarize content try { - const [summarizeResp] = await lightBot.chat(prompts.renderSummarizeFileDiff(ins, options.reviewSimpleChanges), {}); + const [summarizeResp] = await lightBot.chat(summarizePrompt, {}); if (summarizeResp === '') { (0,core.info)('summarize: nothing obtained from openai'); summariesFailed.push(`${filename} (nothing obtained from openai)`); @@ -7444,7 +7570,7 @@ ${filename}: ${summary} (0,core.info)('release notes: nothing obtained from openai'); } else { - let message = '### Summary by OpenAI\n\n'; + let message = '### Summary by CodeRabbit\n\n'; message += releaseNotesResponse; try { await commenter.updateDescription(context.payload.pull_request.number, message); @@ -7464,54 +7590,34 @@ ${lib_commenter/* RAW_SUMMARY_END_TAG */.rV} ${lib_commenter/* SHORT_SUMMARY_START_TAG */.O$} ${inputs.shortSummary} ${lib_commenter/* SHORT_SUMMARY_END_TAG */.Zb} ---- - -### Chat with 🤖 OpenAI Bot (\`@openai\`) -- Reply on review comments left by this bot to ask follow-up questions. A review comment is a comment on a diff or a file. -- Invite the bot into a review comment chain by tagging \`@openai\` in a reply. - -### Code suggestions -- The bot may make code suggestions, but please review them carefully before committing since the line number ranges may be misaligned. -- You can edit the comment made by the bot and manually tweak the suggestion if it is slightly off. - -### Ignoring further reviews -- Type \`@openai: ignore\` anywhere in the PR description to ignore further reviews from the bot. --- -${filterIgnoredFiles.length > 0 - ? `
-Files ignored due to filter (${filterIgnoredFiles.length}) +Uplevel your code reviews with CodeRabbit Pro -### Ignored files +### CodeRabbit Pro -* ${filterIgnoredFiles.map(file => file.filename).join('\n* ')} +If you like this project, please support us by purchasing the [Pro version](https://coderabbit.ai). The Pro version has advanced context, superior noise reduction and several proprietary improvements compared to the open source version. Moreover, CodeRabbit Pro is free for open source projects.
-` - : ''} - +`; + statusMsg += ` ${skippedFiles.length > 0 ? `
Files not processed due to max files limit (${skippedFiles.length}) -### Not processed - * ${skippedFiles.join('\n* ')}
` : ''} - ${summariesFailed.length > 0 ? `
Files not summarized due to errors (${summariesFailed.length}) -### Failed to summarize - * ${summariesFailed.join('\n* ')}
@@ -7528,6 +7634,8 @@ ${summariesFailed.length > 0 .map(([filename]) => filename); // failed reviews array const reviewsFailed = []; + let lgtmCount = 0; + let reviewCount = 0; const doReview = async (filename, fileContent, patches) => { (0,core.info)(`reviewing ${filename}`); // make a copy of inputs @@ -7596,38 +7704,45 @@ ${commentChain} ---end_change_section--- `; } - // perform review - try { - const [response] = await heavyBot.chat(prompts.renderReviewFileDiff(ins), {}); - if (response === '') { - (0,core.info)('review: nothing obtained from openai'); - reviewsFailed.push(`${filename} (no response)`); - return; - } - // parse review - const reviews = parseReview(response, patches, options.debug); - for (const review of reviews) { - // check for LGTM - if (!options.reviewCommentLGTM && - (review.comment.includes('LGTM') || - review.comment.includes('looks good to me'))) { - continue; - } - if (context.payload.pull_request == null) { - (0,core.warning)('No pull request found, skipping.'); - continue; - } - try { - await commenter.bufferReviewComment(filename, review.startLine, review.endLine, `${review.comment}`); + if (patchesPacked > 0) { + // perform review + try { + const [response] = await heavyBot.chat(prompts.renderReviewFileDiff(ins), {}); + if (response === '') { + (0,core.info)('review: nothing obtained from openai'); + reviewsFailed.push(`${filename} (no response)`); + return; } - catch (e) { - reviewsFailed.push(`${filename} comment failed (${e})`); + // parse review + const reviews = parseReview(response, patches, options.debug); + for (const review of reviews) { + // check for LGTM + if (!options.reviewCommentLGTM && + (review.comment.includes('LGTM') || + review.comment.includes('looks good to me'))) { + lgtmCount += 1; + continue; + } + if (context.payload.pull_request == null) { + (0,core.warning)('No pull request found, skipping.'); + continue; + } + try { + reviewCount += 1; + await commenter.bufferReviewComment(filename, review.startLine, review.endLine, `${review.comment}`); + } + catch (e) { + reviewsFailed.push(`${filename} comment failed (${e})`); + } } } + catch (e) { + (0,core.warning)(`Failed to review: ${e}, skipping. backtrace: ${e.stack}`); + reviewsFailed.push(`${filename} (${e})`); + } } - catch (e) { - (0,core.warning)(`Failed to review: ${e}, skipping. backtrace: ${e.stack}`); - reviewsFailed.push(`${filename} (${e})`); + else { + reviewsSkipped.push(`${filename} (diff too large)`); } }; const reviewPromises = []; @@ -7642,41 +7757,58 @@ ${commentChain} } } await Promise.all(reviewPromises); - summarizeComment += ` ---- -In the recent run, only the files that changed from the \`base\` of the PR and between \`${highestReviewedCommitId}\` and \`${context.payload.pull_request.head.sha}\` commits were reviewed. - + statusMsg += ` ${reviewsFailed.length > 0 ? `
-Files not reviewed due to errors in the recent run (${reviewsFailed.length}) - -### Failed to review in the last run +Files not reviewed due to errors (${reviewsFailed.length}) * ${reviewsFailed.join('\n* ')}
` : ''} - ${reviewsSkipped.length > 0 ? `
-Files not reviewed due to simple changes (${reviewsSkipped.length}) - -### Skipped review in the recent run +Files skipped from review due to trivial changes (${reviewsSkipped.length}) * ${reviewsSkipped.join('\n* ')}
` : ''} +
+Review comments generated (${reviewCount + lgtmCount}) + +* Review: ${reviewCount} +* LGTM: ${lgtmCount} + +
+ +--- + +
+Tips + +### Chat with Image description CodeRabbit Bot (\`@coderabbitai\`) +- Reply on review comments left by this bot to ask follow-up questions. A review comment is a comment on a diff or a file. +- Invite the bot into a review comment chain by tagging \`@coderabbitai\` in a reply. + +### Code suggestions +- The bot may make code suggestions, but please review them carefully before committing since the line number ranges may be misaligned. +- You can edit the comment made by the bot and manually tweak the suggestion if it is slightly off. + +### Pausing incremental reviews +- Add \`@coderabbitai: ignore\` anywhere in the PR description to pause further reviews from the bot. + +
`; // add existing_comment_ids_block with latest head sha summarizeComment += `\n${commenter.addReviewedCommitId(existingCommitIdsBlock, context.payload.pull_request.head.sha)}`; + // post the review + await commenter.submitReview(context.payload.pull_request.number, commits[commits.length - 1].sha, statusMsg); } // post the final summary comment await commenter.comment(`${summarizeComment}`, lib_commenter/* SUMMARIZE_TAG */.Rp, 'replace'); - // post the review - await commenter.submitReview(context.payload.pull_request.number, commits[commits.length - 1].sha); }; const splitPatch = (patch) => { if (patch == null) { @@ -7730,26 +7862,36 @@ const parsePatch = (patch) => { } const oldHunkLines = []; const newHunkLines = []; - // let old_line = hunkInfo.old_hunk.start_line let newLine = hunkInfo.newHunk.startLine; const lines = patch.split('\n').slice(1); // Skip the @@ line // Remove the last line if it's empty if (lines[lines.length - 1] === '') { lines.pop(); } + // Skip annotations for the first 3 and last 3 lines + const skipStart = 3; + const skipEnd = 3; + let currentLine = 0; + const removalOnly = !lines.some(line => line.startsWith('+')); for (const line of lines) { + currentLine++; if (line.startsWith('-')) { oldHunkLines.push(`${line.substring(1)}`); - // old_line++ } else if (line.startsWith('+')) { newHunkLines.push(`${newLine}: ${line.substring(1)}`); newLine++; } else { + // context line oldHunkLines.push(`${line}`); - newHunkLines.push(`${newLine}: ${line}`); - // old_line++ + if (removalOnly || + (currentLine > skipStart && currentLine <= lines.length - skipEnd)) { + newHunkLines.push(`${newLine}: ${line}`); + } + else { + newHunkLines.push(`${line}`); + } newLine++; } } @@ -7760,6 +7902,7 @@ const parsePatch = (patch) => { }; function parseReview(response, patches, debug = false) { const reviews = []; + response = sanitizeResponse(response.trim()); const lines = response.split('\n'); const lineNumberRangeRegex = /(?:^|\s)(\d+)-(\d+):\s*$/; const commentSeparator = '---'; @@ -7768,11 +7911,10 @@ function parseReview(response, patches, debug = false) { let currentComment = ''; function storeReview() { if (currentStartLine !== null && currentEndLine !== null) { - const sanitizedComment = sanitizeComment(currentComment.trim()); const review = { startLine: currentStartLine, endLine: currentEndLine, - comment: sanitizedComment.trim() + comment: currentComment }; let withinPatch = false; let bestPatchStartLine = -1; @@ -7812,28 +7954,33 @@ ${review.comment}`; (0,core.info)(`Stored comment for line range ${currentStartLine}-${currentEndLine}: ${currentComment.trim()}`); } } - function sanitizeComment(comment) { - const suggestionStart = '```suggestion'; - const suggestionEnd = '```'; + function sanitizeCodeBlock(comment, codeBlockLabel) { + const codeBlockStart = `\`\`\`${codeBlockLabel}`; + const codeBlockEnd = '```'; const lineNumberRegex = /^ *(\d+): /gm; - let suggestionStartIndex = comment.indexOf(suggestionStart); - while (suggestionStartIndex !== -1) { - const suggestionEndIndex = comment.indexOf(suggestionEnd, suggestionStartIndex + suggestionStart.length); - if (suggestionEndIndex === -1) + let codeBlockStartIndex = comment.indexOf(codeBlockStart); + while (codeBlockStartIndex !== -1) { + const codeBlockEndIndex = comment.indexOf(codeBlockEnd, codeBlockStartIndex + codeBlockStart.length); + if (codeBlockEndIndex === -1) break; - const suggestionBlock = comment.substring(suggestionStartIndex + suggestionStart.length, suggestionEndIndex); - const sanitizedBlock = suggestionBlock.replace(lineNumberRegex, ''); + const codeBlock = comment.substring(codeBlockStartIndex + codeBlockStart.length, codeBlockEndIndex); + const sanitizedBlock = codeBlock.replace(lineNumberRegex, ''); comment = - comment.slice(0, suggestionStartIndex + suggestionStart.length) + + comment.slice(0, codeBlockStartIndex + codeBlockStart.length) + sanitizedBlock + - comment.slice(suggestionEndIndex); - suggestionStartIndex = comment.indexOf(suggestionStart, suggestionStartIndex + - suggestionStart.length + + comment.slice(codeBlockEndIndex); + codeBlockStartIndex = comment.indexOf(codeBlockStart, codeBlockStartIndex + + codeBlockStart.length + sanitizedBlock.length + - suggestionEnd.length); + codeBlockEnd.length); } return comment; } + function sanitizeResponse(comment) { + comment = sanitizeCodeBlock(comment, 'suggestion'); + comment = sanitizeCodeBlock(comment, 'diff'); + return comment; + } for (const line of lines) { const lineNumberRangeMatch = line.match(lineNumberRangeRegex); if (lineNumberRangeMatch != null) { diff --git a/docs/images/PRSummary.png b/docs/images/PRSummary.png new file mode 100644 index 00000000..bf90664b Binary files /dev/null and b/docs/images/PRSummary.png differ diff --git a/docs/images/ReleaseNotes.png b/docs/images/ReleaseNotes.png new file mode 100644 index 00000000..64da5dce Binary files /dev/null and b/docs/images/ReleaseNotes.png differ diff --git a/docs/images/section-1.png b/docs/images/section-1.png new file mode 100644 index 00000000..b8639bd8 Binary files /dev/null and b/docs/images/section-1.png differ diff --git a/docs/images/section-3.png b/docs/images/section-3.png new file mode 100644 index 00000000..04715f00 Binary files /dev/null and b/docs/images/section-3.png differ diff --git a/src/bot.ts b/src/bot.ts index 054138a0..4a5a1e4b 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -28,7 +28,10 @@ export class Bot { const currentDate = new Date().toISOString().split('T')[0] const systemMessage = `${options.systemMessage} Knowledge cutoff: ${openaiOptions.tokenLimits.knowledgeCutOff} -Current date: ${currentDate}` +Current date: ${currentDate} + +IMPORTANT: Entire response must be in the language with ISO code: ${options.language} +` this.api = new ChatGPTAPI({ apiBaseUrl: options.apiBaseUrl, diff --git a/src/commenter.ts b/src/commenter.ts index 5c3e132b..463e92cf 100644 --- a/src/commenter.ts +++ b/src/commenter.ts @@ -7,34 +7,40 @@ import {octokit} from './octokit' const context = github_context const repo = context.repo -export const COMMENT_GREETING = ':robot: OpenAI' +export const COMMENT_GREETING = `Image description CodeRabbit` export const COMMENT_TAG = - '' + '' export const COMMENT_REPLY_TAG = - '' + '' export const SUMMARIZE_TAG = - '' + '' -export const DESCRIPTION_START_TAG = ` -` +export const IN_PROGRESS_START_TAG = + '' + +export const IN_PROGRESS_END_TAG = + '' + +export const DESCRIPTION_START_TAG = + '' export const DESCRIPTION_END_TAG = - '' + '' -export const RAW_SUMMARY_START_TAG = ` +export const RAW_SUMMARY_START_TAG = ` -` +` -export const SHORT_SUMMARY_START_TAG = ` +export const SHORT_SUMMARY_START_TAG = ` -` +` export const COMMIT_ID_START_TAG = '' export const COMMIT_ID_END_TAG = '' @@ -87,7 +93,7 @@ ${tag}` removeContentWithinTags(content: string, startTag: string, endTag: string) { const start = content.indexOf(startTag) - const end = content.indexOf(endTag) + const end = content.lastIndexOf(endTag) if (start >= 0 && end >= 0) { return content.slice(0, start) + content.slice(end + endTag.length) } @@ -149,7 +155,7 @@ ${tag}` DESCRIPTION_START_TAG, DESCRIPTION_END_TAG ) - const newDescription = `${description}${DESCRIPTION_START_TAG}\n${messageClean}\n${DESCRIPTION_END_TAG}` + const newDescription = `${description}\n${DESCRIPTION_START_TAG}\n${messageClean}\n${DESCRIPTION_END_TAG}` await octokit.pulls.update({ owner: repo.owner, repo: repo.repo, @@ -190,14 +196,67 @@ ${COMMENT_TAG}` }) } - async submitReview(pullNumber: number, commitId: string) { - info( - `Submitting review for PR #${pullNumber}, total comments: ${this.reviewCommentsBuffer.length}` - ) - let commentCounter = 0 + async deletePendingReview(pullNumber: number) { + try { + const reviews = await octokit.pulls.listReviews({ + owner: repo.owner, + repo: repo.repo, + // eslint-disable-next-line camelcase + pull_number: pullNumber + }) + + const pendingReview = reviews.data.find( + review => review.state === 'PENDING' + ) + + if (pendingReview) { + info( + `Deleting pending review for PR #${pullNumber} id: ${pendingReview.id}` + ) + try { + await octokit.pulls.deletePendingReview({ + owner: repo.owner, + repo: repo.repo, + // eslint-disable-next-line camelcase + pull_number: pullNumber, + // eslint-disable-next-line camelcase + review_id: pendingReview.id + }) + } catch (e) { + warning(`Failed to delete pending review: ${e}`) + } + } + } catch (e) { + warning(`Failed to list reviews: ${e}`) + } + } + + async submitReview(pullNumber: number, commitId: string, statusMsg: string) { + const body = `${COMMENT_GREETING} + +${statusMsg} +` + + if (this.reviewCommentsBuffer.length === 0) { + // Submit empty review with statusMsg + info(`Submitting empty review for PR #${pullNumber}`) + try { + await octokit.pulls.createReview({ + owner: repo.owner, + repo: repo.repo, + // eslint-disable-next-line camelcase + pull_number: pullNumber, + // eslint-disable-next-line camelcase + commit_id: commitId, + event: 'COMMENT', + body + }) + } catch (e) { + warning(`Failed to submit empty review: ${e}`) + } + return + } for (const comment of this.reviewCommentsBuffer) { - info(`Posting comment: ${comment.message}`) - let found = false const comments = await this.getCommentsAtRange( pullNumber, comment.path, @@ -207,25 +266,75 @@ ${COMMENT_TAG}` for (const c of comments) { if (c.body.includes(COMMENT_TAG)) { info( - `Updating review comment for ${comment.path}:${comment.startLine}-${comment.endLine}: ${comment.message}` + `Deleting review comment for ${comment.path}:${comment.startLine}-${comment.endLine}: ${comment.message}` ) try { - await octokit.pulls.updateReviewComment({ + await octokit.pulls.deleteReviewComment({ owner: repo.owner, repo: repo.repo, // eslint-disable-next-line camelcase - comment_id: c.id, - body: comment.message + comment_id: c.id }) } catch (e) { - warning(`Failed to update review comment: ${e}`) + warning(`Failed to delete review comment: ${e}`) } - found = true - break } } + } + + await this.deletePendingReview(pullNumber) + + const generateCommentData = (comment: any) => { + const commentData: any = { + path: comment.path, + body: comment.message, + line: comment.endLine + } + + if (comment.startLine !== comment.endLine) { + // eslint-disable-next-line camelcase + commentData.start_line = comment.startLine + // eslint-disable-next-line camelcase + commentData.start_side = 'RIGHT' + } - if (!found) { + return commentData + } + + try { + const review = await octokit.pulls.createReview({ + owner: repo.owner, + repo: repo.repo, + // eslint-disable-next-line camelcase + pull_number: pullNumber, + // eslint-disable-next-line camelcase + commit_id: commitId, + comments: this.reviewCommentsBuffer.map(comment => + generateCommentData(comment) + ) + }) + + info( + `Submitting review for PR #${pullNumber}, total comments: ${this.reviewCommentsBuffer.length}, review id: ${review.data.id}` + ) + + await octokit.pulls.submitReview({ + owner: repo.owner, + repo: repo.repo, + // eslint-disable-next-line camelcase + pull_number: pullNumber, + // eslint-disable-next-line camelcase + review_id: review.data.id, + event: 'COMMENT', + body + }) + } catch (e) { + warning( + `Failed to create review: ${e}. Falling back to individual comments.` + ) + await this.deletePendingReview(pullNumber) + let commentCounter = 0 + for (const comment of this.reviewCommentsBuffer) { info( `Creating new review comment for ${comment.path}:${comment.startLine}-${comment.endLine}: ${comment.message}` ) @@ -236,28 +345,20 @@ ${COMMENT_TAG}` pull_number: pullNumber, // eslint-disable-next-line camelcase commit_id: commitId, - body: comment.message, - path: comment.path, - line: comment.endLine + ...generateCommentData(comment) } - if (comment.startLine !== comment.endLine) { - // eslint-disable-next-line camelcase - commentData.start_side = 'RIGHT' - // eslint-disable-next-line camelcase - commentData.start_line = comment.startLine - } try { await octokit.pulls.createReviewComment(commentData) - } catch (e) { - warning(`Failed to create review comment: ${e}`) + } catch (ee) { + warning(`Failed to create review comment: ${ee}`) } - } - commentCounter++ - info( - `Comment ${commentCounter}/${this.reviewCommentsBuffer.length} posted` - ) + commentCounter++ + info( + `Comment ${commentCounter}/${this.reviewCommentsBuffer.length} posted` + ) + } } } @@ -483,14 +584,20 @@ ${chain} async create(body: string, target: number) { try { - // get commend ID from the response - await octokit.issues.createComment({ + // get comment ID from the response + const response = await octokit.issues.createComment({ owner: repo.owner, repo: repo.repo, // eslint-disable-next-line camelcase issue_number: target, body }) + // add comment to issueCommentsCache + if (this.issueCommentsCache[target]) { + this.issueCommentsCache[target].push(response.data) + } else { + this.issueCommentsCache[target] = [response.data] + } } catch (e) { warning(`Failed to create comment: ${e}`) } @@ -645,4 +752,41 @@ ${chain} return allCommits } + + // add in-progress status to the comment body + addInProgressStatus(commentBody: string, statusMsg: string): string { + const start = commentBody.indexOf(IN_PROGRESS_START_TAG) + const end = commentBody.indexOf(IN_PROGRESS_END_TAG) + // add to the beginning of the comment body if the marker doesn't exist + // otherwise do nothing + if (start === -1 || end === -1) { + return `${IN_PROGRESS_START_TAG} + +Currently reviewing new changes in this PR... + +${statusMsg} + +${IN_PROGRESS_END_TAG} + +--- + +${commentBody}` + } + return commentBody + } + + // remove in-progress status from the comment body + removeInProgressStatus(commentBody: string): string { + const start = commentBody.indexOf(IN_PROGRESS_START_TAG) + const end = commentBody.indexOf(IN_PROGRESS_END_TAG) + // remove the in-progress status if the marker exists + // otherwise do nothing + if (start !== -1 && end !== -1) { + return ( + commentBody.substring(0, start) + + commentBody.substring(end + IN_PROGRESS_END_TAG.length) + ) + } + return commentBody + } } diff --git a/src/limits.ts b/src/limits.ts index 7c2afc52..aca807f6 100644 --- a/src/limits.ts +++ b/src/limits.ts @@ -9,6 +9,9 @@ export class TokenLimits { if (model === 'gpt-4-32k') { this.maxTokens = 32600 this.responseTokens = 4000 + } else if (model === 'gpt-3.5-turbo-16k') { + this.maxTokens = 16300 + this.responseTokens = 3000 } else if (model === 'gpt-4') { this.maxTokens = 8000 this.responseTokens = 2000 diff --git a/src/main.ts b/src/main.ts index e0b69db0..0b716c48 100644 --- a/src/main.ts +++ b/src/main.ts @@ -27,7 +27,9 @@ async function run(): Promise { getInput('openai_retries'), getInput('openai_timeout_ms'), getInput('openai_concurrency_limit'), - getInput('openai_base_url') + getInput('github_concurrency_limit'), + getInput('openai_base_url'), + getInput('language') ) // print options diff --git a/src/octokit.ts b/src/octokit.ts index d08e4f02..a743d3eb 100644 --- a/src/octokit.ts +++ b/src/octokit.ts @@ -22,17 +22,23 @@ Retry after: ${retryAfter} seconds Retry count: ${retryCount} ` ) - return true + if (retryCount <= 3) { + warning(`Retrying after ${retryAfter} seconds!`) + return true + } }, - onSecondaryRateLimit: (_retryAfter: number, options: any) => { + onSecondaryRateLimit: (retryAfter: number, options: any) => { warning( - `SecondaryRateLimit detected for request ${options.method} ${options.url}` + `SecondaryRateLimit detected for request ${options.method} ${options.url} ; retry after ${retryAfter} seconds` ) + // if we are doing a POST method on /repos/{owner}/{repo}/pulls/{pull_number}/reviews then we shouldn't retry + if ( + options.method === 'POST' && + options.url.match(/\/repos\/.*\/.*\/pulls\/.*\/reviews/) + ) { + return false + } return true } - }, - retry: { - doNotRetry: ['429'], - maxRetries: 10 } }) diff --git a/src/options.ts b/src/options.ts index cc0bbe96..02541975 100644 --- a/src/options.ts +++ b/src/options.ts @@ -17,9 +17,11 @@ export class Options { openaiRetries: number openaiTimeoutMS: number openaiConcurrencyLimit: number + githubConcurrencyLimit: number lightTokenLimits: TokenLimits heavyTokenLimits: TokenLimits apiBaseUrl: string + language: string constructor( debug: boolean, @@ -35,8 +37,10 @@ export class Options { openaiModelTemperature = '0.0', openaiRetries = '3', openaiTimeoutMS = '120000', - openaiConcurrencyLimit = '4', - apiBaseUrl = 'https://api.openai.com/v1' + openaiConcurrencyLimit = '6', + githubConcurrencyLimit = '6', + apiBaseUrl = 'https://api.openai.com/v1', + language = 'en-US' ) { this.debug = debug this.disableReview = disableReview @@ -52,9 +56,11 @@ export class Options { this.openaiRetries = parseInt(openaiRetries) this.openaiTimeoutMS = parseInt(openaiTimeoutMS) this.openaiConcurrencyLimit = parseInt(openaiConcurrencyLimit) + this.githubConcurrencyLimit = parseInt(githubConcurrencyLimit) this.lightTokenLimits = new TokenLimits(openaiLightModel) this.heavyTokenLimits = new TokenLimits(openaiHeavyModel) this.apiBaseUrl = apiBaseUrl + this.language = language } // print all options using core.info @@ -73,9 +79,11 @@ export class Options { info(`openai_retries: ${this.openaiRetries}`) info(`openai_timeout_ms: ${this.openaiTimeoutMS}`) info(`openai_concurrency_limit: ${this.openaiConcurrencyLimit}`) + info(`github_concurrency_limit: ${this.githubConcurrencyLimit}`) info(`summary_token_limits: ${this.lightTokenLimits.string()}`) info(`review_token_limits: ${this.heavyTokenLimits.string()}`) info(`api_base_url: ${this.apiBaseUrl}`) + info(`language: ${this.language}`) } checkPath(path: string): boolean { diff --git a/src/prompts.ts b/src/prompts.ts index 265d2aaa..32167d85 100644 --- a/src/prompts.ts +++ b/src/prompts.ts @@ -22,7 +22,11 @@ $file_diff ## Instructions -I would like you to summarize the diff within 50 words. +I would like you to succinctly summarize the diff within 100 words. +If applicable, your summary should include a note about alterations +to the signatures of exported functions, global data structures and +variables, and any changes that might affect the external interface or +behavior of the code. ` triageFileDiff = `Below the summary, I would also like you to triage the diff as \`NEEDS_REVIEW\` or \`APPROVED\` based on the following criteria: @@ -66,9 +70,9 @@ $raw_summary ` - summarizeShort = `Your task is to provide a concise summary of the changes -and the goal of this PR. This summary will be used as a prompt while reviewing each -file and must be very clear for the AI bot to understand. + summarizeShort = `Your task is to provide a concise summary of the changes. This +summary will be used as a prompt while reviewing each file and must be very clear for +the AI bot to understand. Instructions: @@ -76,7 +80,7 @@ Instructions: - Do not provide any instructions to the bot on how to perform the review. - Do not mention that files need a through review or caution about potential issues. - Do not mention that these changes affect the logic or functionality of the code. -- The summary should not exceed 250 words. +- The summary should not exceed 500 words. ` reviewFileDiff = `## GitHub PR Title @@ -89,136 +93,83 @@ Instructions: $description \`\`\` -## Summary generated by the AI bot +## Summary of changes \`\`\` $short_summary \`\`\` -## How to parse the changes - -The format for changes provided below consists of multiple change -sections, each containing a new hunk (annotated with line numbers), -an old hunk, and optionally, existing comment chains. Note that the -old hunk code has been replaced by the new hunk to fulfill the -goal of this PR. The line number annotation on each line in the new -hunk is of the format \`\`. - -### Format for changes - - ---new_hunk--- - \`\`\` - - \`\`\` - - ---old_hunk--- - \`\`\` - - \`\`\` - - ---comment_chains--- - \`\`\` - - \`\`\` - - ---end_change_section--- - ... - -## Response Instructions - -- Your task is to review ONLY the new hunks line by line, ONLY pointing out - substantive issues within line number ranges. Provide the exact line - number range (inclusive) for each issue. Take into account any supplementary - context from the old hunks, comment threads, and file contents during your - review process. Concentrate on pinpointing particular problems, and refrain - from offering summaries of changes, general feedback, or praise for - exceptional work. -- Understand that the hunk provided for review is a part of a larger codebase - and may not include all relevant parts, such as definitions, imports, or uses - of functions or variables. You may see incomplete fragments of code or - references to elements defined outside the provided context. Refrain from - flagging issues about missing definitions, imports, or uses unless there is - strong evidence within the provided context to suggest there might be a problem. -- IMPORTANT: Respond only in the below response format (consisting of review - sections). Each review section must have a line number range and a review - comment for that range. Do not include general feedback or summaries. You - may optionally include a single replacement suggestion snippet and/or - multiple new code snippets in the review comment. Separate review sections - using separators. -- IMPORTANT: Line number ranges for each review section must be within the - range of a specific new hunk. must belong to the same - hunk as the . The line number range is sufficient to map - your comment to the code changes in the GitHub pull request. -- Use Markdown format for review comment text and fenced code blocks for - code snippets. Do not annotate code snippets with line numbers. -- If needed, provide replacement code suggestions to fix the issue by using - fenced code blocks with the \`suggestion\` as the language identifier. The - line number range must map exactly to the range (inclusive) that needs to - be replaced within a new hunk. For instance, if 2 lines of code in a hunk - need to be replaced with 15 lines of code, the line number range must be - those exact 2 lines. If an entire hunk need to be replaced with new code, - then the line number range must be the entire hunk and the new code must - exactly replace all the lines in the hunk. -- Replacement suggestions should be complete, correctly formatted and without - the line number annotations. Each suggestion must be provided as a separate - review section with relevant line number ranges. -- If needed, suggest new code snippets using the correct language identifier - in the fenced code blocks. These snippets may be added to a different file - (e.g. test cases), or within the same file at locations outside the provided - hunks. Multiple new code snippets are allowed within a single review section. -- IMPORTANT: If there are no issues found on a line range, you must only - respond with the text \`LGTM!\` for that line range in the review section. -- Reflect on your comments and line number ranges before sending the final - response to ensure accuracy of line number ranges and replacement snippets. - -### Response format expected - - -: - - --- - -: - - \`\`\`suggestion - - \`\`\` - --- - -: - - \`\`\` - - \`\`\` - --- - ... +## IMPORTANT Instructions + +Input: New hunks annotated with line numbers and old hunks (replaced code). Hunks represent incomplete code fragments. +Additional Context: PR title, description, summaries and comment chains. +Task: Review new hunks for substantive issues using provided context and respond with comments if necessary. +Output: Review comments in markdown with exact line number ranges in new hunks. Start and end line numbers must be within the same hunk. For single-line comments, start=end line number. Must use example response format below. +Use fenced code blocks using the relevant language identifier where applicable. +Don't annotate code snippets with line numbers. Format and indent code correctly. +Do not use \`suggestion\` code blocks. +For fixes, use \`diff\` code blocks, marking changes with \`+\` or \`-\`. The line number range for comments with fix snippets must exactly match the range to replace in the new hunk. + +- Do NOT provide general feedback, summaries, explanations of changes, or praises + for making good additions. +- Focus solely on offering specific, objective insights based on the + given context and refrain from making broad comments about potential impacts on + the system or question intentions behind the changes. + +If there are no issues found on a line range, you MUST respond with the +text \`LGTM!\` for that line range in the review section. ## Example ### Example changes - ---new_hunk--- - 1: def add(x, y): - 2: z = x+y - 3: retrn z - 4: - 5: def multiply(x, y): - 6: return x * y +---new_hunk--- +\`\`\` + z = x / y + return z + +20: def add(x, y): +21: z = x + y +22: retrn z +23: +24: def multiply(x, y): +25: return x * y + +def subtract(x, y): + z = x - y +\`\`\` - ---old_hunk--- - def add(x, y): - return x + y +---old_hunk--- +\`\`\` + z = x / y + return z + +def add(x, y): + return x + y + +def subtract(x, y): + z = x - y +\`\`\` + +---comment_chains--- +\`\`\` +Please review this change. +\`\`\` + +---end_change_section--- ### Example response - 1-3: - There's a typo in the return statement. - \`\`\`suggestion - def add(x, y): - z = x + y - return z - \`\`\` - --- - 5-6: - LGTM! - --- +22-22: +There's a syntax error in the add function. +\`\`\`diff +- retrn z ++ return z +\`\`\` +--- +24-25: +LGTM! +--- ## Changes made to \`$filename\` for your review diff --git a/src/review-comment.ts b/src/review-comment.ts index 1a5b0b17..f42c53db 100644 --- a/src/review-comment.ts +++ b/src/review-comment.ts @@ -17,7 +17,7 @@ import {getTokenCount} from './tokenizer' // eslint-disable-next-line camelcase const context = github_context const repo = context.repo -const ASK_BOT = '@openai' +const ASK_BOT = '@coderabbitai' export const handleReviewComment = async ( heavyBot: Bot, diff --git a/src/review.ts b/src/review.ts index 58bcb1ee..5e7dd9db 100644 --- a/src/review.ts +++ b/src/review.ts @@ -22,7 +22,7 @@ import {getTokenCount} from './tokenizer' const context = github_context const repo = context.repo -const ignoreKeyword = '@openai: ignore' +const ignoreKeyword = '@coderabbitai: ignore' export const codeReview = async ( lightBot: Bot, @@ -33,6 +33,7 @@ export const codeReview = async ( const commenter: Commenter = new Commenter() const openaiConcurrencyLimit = pLimit(options.openaiConcurrencyLimit) + const githubConcurrencyLimit = pLimit(options.githubConcurrencyLimit) if ( context.eventName !== 'pull_request' && @@ -71,11 +72,13 @@ export const codeReview = async ( context.payload.pull_request.number ) let existingCommitIdsBlock = '' + let existingSummarizeCmtBody = '' if (existingSummarizeCmt != null) { - inputs.rawSummary = commenter.getRawSummary(existingSummarizeCmt.body) - inputs.shortSummary = commenter.getShortSummary(existingSummarizeCmt.body) + existingSummarizeCmtBody = existingSummarizeCmt.body + inputs.rawSummary = commenter.getRawSummary(existingSummarizeCmtBody) + inputs.shortSummary = commenter.getShortSummary(existingSummarizeCmtBody) existingCommitIdsBlock = commenter.getReviewedCommitIdsBlock( - existingSummarizeCmt.body + existingSummarizeCmtBody ) } @@ -139,13 +142,6 @@ export const codeReview = async ( return } - const commits = incrementalDiff.data.commits - - if (commits.length === 0) { - warning('Skipped: ommits is null') - return - } - // skip files if they are filtered out const filterSelectedFiles = [] const filterIgnoredFiles = [] @@ -158,61 +154,74 @@ export const codeReview = async ( } } + if (filterSelectedFiles.length === 0) { + warning('Skipped: filterSelectedFiles is null') + return + } + + const commits = incrementalDiff.data.commits + + if (commits.length === 0) { + warning('Skipped: commits is null') + return + } + // find hunks to review const filteredFiles: Array< [string, string, string, Array<[number, number, string]>] | null > = await Promise.all( - filterSelectedFiles.map(async file => { - // retrieve file contents - let fileContent = '' - if (context.payload.pull_request == null) { - warning('Skipped: context.payload.pull_request is null') - return null - } - try { - const contents = await octokit.repos.getContent({ - owner: repo.owner, - repo: repo.repo, - path: file.filename, - ref: context.payload.pull_request.base.sha - }) - if (contents.data != null) { - if (!Array.isArray(contents.data)) { - if ( - contents.data.type === 'file' && - contents.data.content != null - ) { - fileContent = Buffer.from( - contents.data.content, - 'base64' - ).toString() + filterSelectedFiles.map(file => + githubConcurrencyLimit(async () => { + // retrieve file contents + let fileContent = '' + if (context.payload.pull_request == null) { + warning('Skipped: context.payload.pull_request is null') + return null + } + try { + const contents = await octokit.repos.getContent({ + owner: repo.owner, + repo: repo.repo, + path: file.filename, + ref: context.payload.pull_request.base.sha + }) + if (contents.data != null) { + if (!Array.isArray(contents.data)) { + if ( + contents.data.type === 'file' && + contents.data.content != null + ) { + fileContent = Buffer.from( + contents.data.content, + 'base64' + ).toString() + } } } + } catch (e: any) { + warning( + `Failed to get file contents: ${ + e as string + }. This is OK if it's a new file.` + ) } - } catch (e: any) { - warning( - `Failed to get file contents: ${ - e as string - }. This is OK if it's a new file.` - ) - } - let fileDiff = '' - if (file.patch != null) { - fileDiff = file.patch - } - - const patches: Array<[number, number, string]> = [] - for (const patch of splitPatch(file.patch)) { - const patchLines = patchStartEndLine(patch) - if (patchLines == null) { - continue - } - const hunks = parsePatch(patch) - if (hunks == null) { - continue + let fileDiff = '' + if (file.patch != null) { + fileDiff = file.patch } - const hunksStr = ` + + const patches: Array<[number, number, string]> = [] + for (const patch of splitPatch(file.patch)) { + const patchLines = patchStartEndLine(patch) + if (patchLines == null) { + continue + } + const hunks = parsePatch(patch) + if (hunks == null) { + continue + } + const hunksStr = ` ---new_hunk--- \`\`\` ${hunks.newHunk} @@ -223,18 +232,24 @@ ${hunks.newHunk} ${hunks.oldHunk} \`\`\` ` - patches.push([ - patchLines.newHunk.startLine, - patchLines.newHunk.endLine, - hunksStr - ]) - } - if (patches.length > 0) { - return [file.filename, fileContent, fileDiff, patches] - } else { - return null - } - }) + patches.push([ + patchLines.newHunk.startLine, + patchLines.newHunk.endLine, + hunksStr + ]) + } + if (patches.length > 0) { + return [file.filename, fileContent, fileDiff, patches] as [ + string, + string, + string, + Array<[number, number, string]> + ] + } else { + return null + } + }) + ) ) // Filter out any null results @@ -247,6 +262,48 @@ ${hunks.oldHunk} return } + let statusMsg = `
+Commits +Files that changed from the base of the PR and between ${highestReviewedCommitId} and ${ + context.payload.pull_request.head.sha + } commits. +
+${ + filesAndChanges.length > 0 + ? ` +
+Files selected (${filesAndChanges.length}) + +* ${filesAndChanges + .map(([filename, , , patches]) => `${filename} (${patches.length})`) + .join('\n* ')} +
+` + : '' +} +${ + filterIgnoredFiles.length > 0 + ? ` +
+Files ignored due to filter (${filterIgnoredFiles.length}) + +* ${filterIgnoredFiles.map(file => file.filename).join('\n* ')} + +
+` + : '' +} +` + + // update the existing comment with in progress status + const inProgressSummarizeCmt = commenter.addInProgressStatus( + existingSummarizeCmtBody, + statusMsg + ) + + // add in progress status to the summarize comment + await commenter.comment(`${inProgressSummarizeCmt}`, SUMMARIZE_TAG, 'replace') + const summariesFailed: string[] = [] const doSummary = async ( @@ -263,28 +320,24 @@ ${hunks.oldHunk} } ins.filename = filename + ins.fileDiff = fileDiff // render prompt based on inputs so far - let tokens = getTokenCount( - prompts.renderSummarizeFileDiff(ins, options.reviewSimpleChanges) + const summarizePrompt = prompts.renderSummarizeFileDiff( + ins, + options.reviewSimpleChanges ) + const tokens = getTokenCount(summarizePrompt) - const diffTokens = getTokenCount(fileDiff) - if (tokens + diffTokens > options.lightTokenLimits.requestTokens) { + if (tokens > options.lightTokenLimits.requestTokens) { info(`summarize: diff tokens exceeds limit, skip ${filename}`) summariesFailed.push(`${filename} (diff tokens exceeds limit)`) return null } - ins.fileDiff = fileDiff - tokens += fileDiff.length - // summarize content try { - const [summarizeResp] = await lightBot.chat( - prompts.renderSummarizeFileDiff(ins, options.reviewSimpleChanges), - {} - ) + const [summarizeResp] = await lightBot.chat(summarizePrompt, {}) if (summarizeResp === '') { info('summarize: nothing obtained from openai') @@ -377,7 +430,7 @@ ${filename}: ${summary} if (releaseNotesResponse === '') { info('release notes: nothing obtained from openai') } else { - let message = '### Summary by OpenAI\n\n' + let message = '### Summary by CodeRabbit\n\n' message += releaseNotesResponse try { await commenter.updateDescription( @@ -404,36 +457,20 @@ ${RAW_SUMMARY_END_TAG} ${SHORT_SUMMARY_START_TAG} ${inputs.shortSummary} ${SHORT_SUMMARY_END_TAG} ---- - -### Chat with 🤖 OpenAI Bot (\`@openai\`) -- Reply on review comments left by this bot to ask follow-up questions. A review comment is a comment on a diff or a file. -- Invite the bot into a review comment chain by tagging \`@openai\` in a reply. - -### Code suggestions -- The bot may make code suggestions, but please review them carefully before committing since the line number ranges may be misaligned. -- You can edit the comment made by the bot and manually tweak the suggestion if it is slightly off. - -### Ignoring further reviews -- Type \`@openai: ignore\` anywhere in the PR description to ignore further reviews from the bot. --- -${ - filterIgnoredFiles.length > 0 - ? `
-Files ignored due to filter (${filterIgnoredFiles.length}) +Uplevel your code reviews with CodeRabbit Pro -### Ignored files +### CodeRabbit Pro -* ${filterIgnoredFiles.map(file => file.filename).join('\n* ')} +If you like this project, please support us by purchasing the [Pro version](https://coderabbit.ai). The Pro version has advanced context, superior noise reduction and several proprietary improvements compared to the open source version. Moreover, CodeRabbit Pro is free for open source projects.
` - : '' -} + statusMsg += ` ${ skippedFiles.length > 0 ? ` @@ -442,15 +479,12 @@ ${ skippedFiles.length }) -### Not processed - * ${skippedFiles.join('\n* ')} ` : '' } - ${ summariesFailed.length > 0 ? ` @@ -459,8 +493,6 @@ ${ summariesFailed.length }) -### Failed to summarize - * ${summariesFailed.join('\n* ')} @@ -468,6 +500,7 @@ ${ : '' } ` + if (!options.disableReview) { const filesAndChangesReview = filesAndChanges.filter(([filename]) => { const needsReview = @@ -488,6 +521,8 @@ ${ // failed reviews array const reviewsFailed: string[] = [] + let lgtmCount = 0 + let reviewCount = 0 const doReview = async ( filename: string, fileContent: string, @@ -581,51 +616,57 @@ ${commentChain} ` } - // perform review - try { - const [response] = await heavyBot.chat( - prompts.renderReviewFileDiff(ins), - {} - ) - if (response === '') { - info('review: nothing obtained from openai') - reviewsFailed.push(`${filename} (no response)`) - return - } - // parse review - const reviews = parseReview(response, patches, options.debug) - for (const review of reviews) { - // check for LGTM - if ( - !options.reviewCommentLGTM && - (review.comment.includes('LGTM') || - review.comment.includes('looks good to me')) - ) { - continue - } - if (context.payload.pull_request == null) { - warning('No pull request found, skipping.') - continue + if (patchesPacked > 0) { + // perform review + try { + const [response] = await heavyBot.chat( + prompts.renderReviewFileDiff(ins), + {} + ) + if (response === '') { + info('review: nothing obtained from openai') + reviewsFailed.push(`${filename} (no response)`) + return } + // parse review + const reviews = parseReview(response, patches, options.debug) + for (const review of reviews) { + // check for LGTM + if ( + !options.reviewCommentLGTM && + (review.comment.includes('LGTM') || + review.comment.includes('looks good to me')) + ) { + lgtmCount += 1 + continue + } + if (context.payload.pull_request == null) { + warning('No pull request found, skipping.') + continue + } - try { - await commenter.bufferReviewComment( - filename, - review.startLine, - review.endLine, - `${review.comment}` - ) - } catch (e: any) { - reviewsFailed.push(`${filename} comment failed (${e as string})`) + try { + reviewCount += 1 + await commenter.bufferReviewComment( + filename, + review.startLine, + review.endLine, + `${review.comment}` + ) + } catch (e: any) { + reviewsFailed.push(`${filename} comment failed (${e as string})`) + } } + } catch (e: any) { + warning( + `Failed to review: ${e as string}, skipping. backtrace: ${ + e.stack as string + }` + ) + reviewsFailed.push(`${filename} (${e as string})`) } - } catch (e: any) { - warning( - `Failed to review: ${e as string}, skipping. backtrace: ${ - e.stack as string - }` - ) - reviewsFailed.push(`${filename} (${e as string})`) + } else { + reviewsSkipped.push(`${filename} (diff too large)`) } } @@ -644,20 +685,11 @@ ${commentChain} await Promise.all(reviewPromises) - summarizeComment += ` ---- -In the recent run, only the files that changed from the \`base\` of the PR and between \`${highestReviewedCommitId}\` and \`${ - context.payload.pull_request.head.sha - }\` commits were reviewed. - + statusMsg += ` ${ reviewsFailed.length > 0 ? `
-Files not reviewed due to errors in the recent run (${ - reviewsFailed.length - }) - -### Failed to review in the last run +Files not reviewed due to errors (${reviewsFailed.length}) * ${reviewsFailed.join('\n* ')} @@ -665,38 +697,61 @@ ${ ` : '' } - ${ reviewsSkipped.length > 0 ? `
-Files not reviewed due to simple changes (${ +Files skipped from review due to trivial changes (${ reviewsSkipped.length }) -### Skipped review in the recent run - * ${reviewsSkipped.join('\n* ')}
` : '' } +
+Review comments generated (${reviewCount + lgtmCount}) + +* Review: ${reviewCount} +* LGTM: ${lgtmCount} + +
+ +--- + +
+Tips + +### Chat with Image description CodeRabbit Bot (\`@coderabbitai\`) +- Reply on review comments left by this bot to ask follow-up questions. A review comment is a comment on a diff or a file. +- Invite the bot into a review comment chain by tagging \`@coderabbitai\` in a reply. + +### Code suggestions +- The bot may make code suggestions, but please review them carefully before committing since the line number ranges may be misaligned. +- You can edit the comment made by the bot and manually tweak the suggestion if it is slightly off. + +### Pausing incremental reviews +- Add \`@coderabbitai: ignore\` anywhere in the PR description to pause further reviews from the bot. + +
` // add existing_comment_ids_block with latest head sha summarizeComment += `\n${commenter.addReviewedCommitId( existingCommitIdsBlock, context.payload.pull_request.head.sha )}` + + // post the review + await commenter.submitReview( + context.payload.pull_request.number, + commits[commits.length - 1].sha, + statusMsg + ) } // post the final summary comment await commenter.comment(`${summarizeComment}`, SUMMARIZE_TAG, 'replace') - - // post the review - await commenter.submitReview( - context.payload.pull_request.number, - commits[commits.length - 1].sha - ) } const splitPatch = (patch: string | null | undefined): string[] => { @@ -762,7 +817,6 @@ const parsePatch = ( const oldHunkLines: string[] = [] const newHunkLines: string[] = [] - // let old_line = hunkInfo.old_hunk.start_line let newLine = hunkInfo.newHunk.startLine const lines = patch.split('\n').slice(1) // Skip the @@ line @@ -772,17 +826,32 @@ const parsePatch = ( lines.pop() } + // Skip annotations for the first 3 and last 3 lines + const skipStart = 3 + const skipEnd = 3 + + let currentLine = 0 + + const removalOnly = !lines.some(line => line.startsWith('+')) + for (const line of lines) { + currentLine++ if (line.startsWith('-')) { oldHunkLines.push(`${line.substring(1)}`) - // old_line++ } else if (line.startsWith('+')) { newHunkLines.push(`${newLine}: ${line.substring(1)}`) newLine++ } else { + // context line oldHunkLines.push(`${line}`) - newHunkLines.push(`${newLine}: ${line}`) - // old_line++ + if ( + removalOnly || + (currentLine > skipStart && currentLine <= lines.length - skipEnd) + ) { + newHunkLines.push(`${newLine}: ${line}`) + } else { + newHunkLines.push(`${line}`) + } newLine++ } } @@ -806,6 +875,8 @@ function parseReview( ): Review[] { const reviews: Review[] = [] + response = sanitizeResponse(response.trim()) + const lines = response.split('\n') const lineNumberRangeRegex = /(?:^|\s)(\d+)-(\d+):\s*$/ const commentSeparator = '---' @@ -815,11 +886,10 @@ function parseReview( let currentComment = '' function storeReview(): void { if (currentStartLine !== null && currentEndLine !== null) { - const sanitizedComment = sanitizeComment(currentComment.trim()) const review: Review = { startLine: currentStartLine, endLine: currentEndLine, - comment: sanitizedComment.trim() + comment: currentComment } let withinPatch = false @@ -870,44 +940,50 @@ ${review.comment}` } } - function sanitizeComment(comment: string): string { - const suggestionStart = '```suggestion' - const suggestionEnd = '```' + function sanitizeCodeBlock(comment: string, codeBlockLabel: string): string { + const codeBlockStart = `\`\`\`${codeBlockLabel}` + const codeBlockEnd = '```' const lineNumberRegex = /^ *(\d+): /gm - let suggestionStartIndex = comment.indexOf(suggestionStart) + let codeBlockStartIndex = comment.indexOf(codeBlockStart) - while (suggestionStartIndex !== -1) { - const suggestionEndIndex = comment.indexOf( - suggestionEnd, - suggestionStartIndex + suggestionStart.length + while (codeBlockStartIndex !== -1) { + const codeBlockEndIndex = comment.indexOf( + codeBlockEnd, + codeBlockStartIndex + codeBlockStart.length ) - if (suggestionEndIndex === -1) break + if (codeBlockEndIndex === -1) break - const suggestionBlock = comment.substring( - suggestionStartIndex + suggestionStart.length, - suggestionEndIndex + const codeBlock = comment.substring( + codeBlockStartIndex + codeBlockStart.length, + codeBlockEndIndex ) - const sanitizedBlock = suggestionBlock.replace(lineNumberRegex, '') + const sanitizedBlock = codeBlock.replace(lineNumberRegex, '') comment = - comment.slice(0, suggestionStartIndex + suggestionStart.length) + + comment.slice(0, codeBlockStartIndex + codeBlockStart.length) + sanitizedBlock + - comment.slice(suggestionEndIndex) + comment.slice(codeBlockEndIndex) - suggestionStartIndex = comment.indexOf( - suggestionStart, - suggestionStartIndex + - suggestionStart.length + + codeBlockStartIndex = comment.indexOf( + codeBlockStart, + codeBlockStartIndex + + codeBlockStart.length + sanitizedBlock.length + - suggestionEnd.length + codeBlockEnd.length ) } return comment } + function sanitizeResponse(comment: string): string { + comment = sanitizeCodeBlock(comment, 'suggestion') + comment = sanitizeCodeBlock(comment, 'diff') + return comment + } + for (const line of lines) { const lineNumberRangeMatch = line.match(lineNumberRangeRegex)