diff --git a/.github/workflows/chatgpt.yaml b/.github/workflows/chatgpt-review.yml similarity index 94% rename from .github/workflows/chatgpt.yaml rename to .github/workflows/chatgpt-review.yml index c6e7fb4e..93d16368 100644 --- a/.github/workflows/chatgpt.yaml +++ b/.github/workflows/chatgpt-review.yml @@ -26,4 +26,4 @@ jobs: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} with: debug: true - review_comment_lgtm: true + review_comment_lgtm: false diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 046f49f5..00000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "chatgpt-api"] - path = chatgpt-api - url = https://github.com/sighingnow/chatgpt-api.git diff --git a/README.md b/README.md index 84d0e73d..3e037dde 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # ChatGPT based PR reviewer and summarizer +![AI](./docs/images/ai.png) + ## Overview This [ChatGPT](https://platform.openai.com/docs/guides/chat) based GitHub Action provides a summary, release notes and review of pull requests. The prompts have been tuned for concise response. To prevent excessive notifications, this action can be configured to skip adding review comments when the changes look good for the most part. @@ -173,7 +175,6 @@ Set `debug: true` in the workflow file to enable debug mode, which will show the ### Special Thanks -This GitHub Action is based on +This GitHub Action is based on [ChatGPT Action](https://github.com/unsafecoerce/chatgpt-pr-reviewer) by [Tao He](https://github.com/sighingnow). - diff --git a/action.yml b/action.yml index 54852eb7..1a638b95 100644 --- a/action.yml +++ b/action.yml @@ -12,7 +12,11 @@ inputs: temperature: required: false description: 'Temperature for ChatGPT model' - default: '0.1' + default: '0.0' + review_comment_lgtm: + required: false + description: 'Leave comments even if the patch is LGTM' + default: 'false' path_filters: required: false description: | @@ -22,6 +26,7 @@ inputs: - https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#onpushpull_requestpull_request_targetpathspaths-ignore - https://github.com/isaacs/minimatch default: | + !dist/** !**/*.pb.go !**/*.lock !**/*.yaml @@ -51,63 +56,6 @@ inputs: description: | The URL of the chatgpt reverse proxy, see also https://github.com/transitive-bullshit/chatgpt-api#reverse-proxy default: https://chat.duti.tech/api/conversation - review_comment_lgtm: - required: false - description: 'Leave comments even if the patch is LGTM' - default: false - review_beginning: - required: false - description: 'The beginning prompt of a code review dialog' - default: | - $system_message - - I will be providing you some files and entire diff to help you build - context, in case the content is not too large. Then I will be sending you each patch - from the diff for review. - - I have a pull request with title "$title" and the description is as follows, - - > $description. - - Reply "OK" to confirm that you are ready for further instructions. - review_file: - required: false - description: 'The prompt for each file' - default: | - Providing `$filename` content as context. Please use this context when reviewing patches. - - ``` - $file_content - ``` - review_file_diff: - required: false - description: 'The prompt for each file diff' - default: | - Providing entire diff for `$filename` as context. Please use this context when reviewing patches. - - ```diff - $file_diff - ``` - review_patch_begin: - required: false - description: 'The prompt for each file diff' - default: | - Next, I will send you a series of patches, each of them consists of a diff snippet, and you - need to do a brief code review for every patch, and tell me any bug risk or improvement - suggestion. If the patch looks good and acceptable, please reply "LGTM!" with a short - comment within 30 words. - - Your responses will be recorded as review comments on the pull request. Markdown format is - preferred for your responses. Reply "OK" to confirm. - review_patch: - required: false - description: 'The prompt for each chunks/patches' - default: | - $filename - - ```diff - $patch - ``` summarize_beginning: required: false description: 'The prompt for the whole pull request' @@ -166,6 +114,63 @@ inputs: e.g. "New Feature: An integrations page was added to the UI". Your response should be as concise as possible (50-100 words), without additional commentary as this response will be used as is in our release notes. + review_beginning: + required: false + description: 'The beginning prompt of a code review dialog' + default: | + $system_message + + I will be providing you some files and entire diff to help you build + context, in case the content is not too large. Then I will be sending you each patch + from the diff for review. + + I have a pull request with title "$title" and the description is as follows, + + > $description. + + ChatGPT generated summary is as follows, + + > $summary + + Reply "OK" to confirm that you are ready for further instructions. + review_file: + required: false + description: 'The prompt for each file' + default: | + Providing `$filename` content as context. Please use this context when reviewing patches. + + ``` + $file_content + ``` + review_file_diff: + required: false + description: 'The prompt for each file diff' + default: | + Providing entire diff for `$filename` as context. Please use this context when reviewing patches. + + ```diff + $file_diff + ``` + review_patch_begin: + required: false + description: 'The prompt for each file diff' + default: | + Next, I will send you a series of patches, each of them consists of a diff snippet, and you + need to do a brief code review for every patch, and tell me any bug risk or improvement + suggestion. If the patch looks good and acceptable, please reply "LGTM!" with a short + comment within 30 words. + + Your responses will be recorded as review comments on the pull request. Markdown format is + preferred for your responses. Reply "OK" to confirm. + review_patch: + required: false + description: 'The prompt for each chunks/patches' + default: | + $filename + + ```diff + $patch + ``` runs: using: 'node16' main: 'dist/index.js' diff --git a/dist/index.js b/dist/index.js index d11de3e4..87f9f201 100644 --- a/dist/index.js +++ b/dist/index.js @@ -26762,7 +26762,7 @@ var ChatGPTUnofficialProxyAPI = class { constructor(opts) { const { accessToken, - apiReverseProxyUrl = "https://chat.duti.tech/api/conversation", + apiReverseProxyUrl = "https://bypass.duti.tech/api/conversation", model = "text-davinci-002-render-sha", debug = false, headers, @@ -28663,15 +28663,17 @@ class Inputs { system_message; title; description; + summary; filename; file_content; file_diff; patch; diff; - constructor(system_message = '', title = '', description = '', filename = '', file_content = '', file_diff = '', patch = '', diff = '') { + constructor(system_message = '', title = '', description = '', summary = '', filename = '', file_content = '', file_diff = '', patch = '', diff = '') { this.system_message = system_message; this.title = title; this.description = description; + this.summary = summary; this.filename = filename; this.file_content = file_content; this.file_diff = file_diff; @@ -28691,6 +28693,9 @@ class Inputs { if (this.description) { content = content.replace('$description', this.description); } + if (this.summary) { + content = content.replace('$summary', this.summary); + } if (this.filename) { content = content.replace('$filename', this.filename); } @@ -28716,7 +28721,7 @@ class Options { path_filters; system_message; temperature; - constructor(debug, chatgpt_reverse_proxy, review_comment_lgtm = false, path_filters = null, system_message = '', temperature = '0.2') { + constructor(debug, chatgpt_reverse_proxy, review_comment_lgtm = false, path_filters = null, system_message = '', temperature = '0.0') { this.debug = debug; this.chatgpt_reverse_proxy = chatgpt_reverse_proxy; this.review_comment_lgtm = review_comment_lgtm; @@ -29093,80 +29098,21 @@ const codeReview = async (bot, options, prompts) => { } if (files_to_review.length > 0) { const commenter = new Commenter(); - const [, review_begin_ids] = await bot.chat(prompts.render_review_beginning(inputs), {}); - let next_review_ids = review_begin_ids; + // Summary Stage const [, summarize_begin_ids] = await bot.chat(prompts.render_summarize_beginning(inputs), {}); let next_summarize_ids = summarize_begin_ids; - for (const [filename, file_content, file_diff, patches] of files_to_review) { + for (const [filename, file_content, file_diff] of files_to_review) { inputs.filename = filename; inputs.file_content = file_content; inputs.file_diff = file_diff; - // reset chat session for each file while reviewing - next_review_ids = review_begin_ids; - if (file_content.length > 0) { - const file_content_tokens = get_token_count(file_content); - if (file_content_tokens < MAX_TOKENS_FOR_EXTRA_CONTENT) { - // review file - const [resp, review_file_ids] = await bot.chat(prompts.render_review_file(inputs), next_review_ids); - if (!resp) { - core.info('review: nothing obtained from chatgpt'); - } - else { - next_review_ids = review_file_ids; - } - } - else { - core.info(`skip sending content of file: ${inputs.filename} due to token count: ${file_content_tokens}`); - } - } if (file_diff.length > 0) { - const file_diff_tokens = get_token_count(file_diff); - if (file_diff_tokens < MAX_TOKENS_FOR_EXTRA_CONTENT) { - // review diff - const [resp, review_diff_ids] = await bot.chat(prompts.render_review_file_diff(inputs), next_review_ids); - if (!resp) { - core.info('review: nothing obtained from chatgpt'); - } - else { - next_review_ids = review_diff_ids; - } - // summarize diff - const [summarize_resp, summarize_diff_ids] = await bot.chat(prompts.render_summarize_file_diff(inputs), next_summarize_ids); - if (!summarize_resp) { - core.info('summarize: nothing obtained from chatgpt'); - } - else { - next_summarize_ids = summarize_diff_ids; - } + // summarize diff + const [summarize_resp, summarize_diff_ids] = await bot.chat(prompts.render_summarize_file_diff(inputs), next_summarize_ids); + if (!summarize_resp) { + core.info('summarize: nothing obtained from chatgpt'); } else { - core.info(`skip sending diff of file: ${inputs.filename} due to token count: ${file_diff_tokens}`); - } - } - // review_patch_begin - const [, patch_begin_ids] = await bot.chat(prompts.render_review_patch_begin(inputs), next_review_ids); - next_review_ids = patch_begin_ids; - for (const [line, patch] of patches) { - core.info(`Reviewing ${filename}:${line} with chatgpt ...`); - inputs.patch = patch; - const [response, patch_ids] = await bot.chat(prompts.render_review_patch(inputs), next_review_ids); - if (!response) { - core.info('review: nothing obtained from chatgpt'); - continue; - } - next_review_ids = patch_ids; - if (!options.review_comment_lgtm && response.includes('LGTM')) { - continue; - } - try { - await commenter.review_comment(review_context.payload.pull_request.number, commits[commits.length - 1].sha, filename, line, `${response}`); - } - catch (e) { - core.warning(`Failed to comment: ${e}, skipping. - backtrace: ${e.stack} - filename: ${filename} - line: ${line} - patch: ${patch}`); + next_summarize_ids = summarize_diff_ids; } } } @@ -29176,6 +29122,7 @@ const codeReview = async (bot, options, prompts) => { core.info('summarize: nothing obtained from chatgpt'); } else { + inputs.summary = summarize_final_response; next_summarize_ids = summarize_final_response_ids; const tag = ''; await commenter.comment(`${summarize_final_response}`, tag, 'replace'); @@ -29229,9 +29176,77 @@ const codeReview = async (bot, options, prompts) => { core.warning(`Failed to get PR: ${e}, skipping adding release notes to description.`); } } + // Review Stage + const [, review_begin_ids] = await bot.chat(prompts.render_review_beginning(inputs), {}); + let next_review_ids = review_begin_ids; + for (const [filename, file_content, file_diff, patches] of files_to_review) { + inputs.filename = filename; + inputs.file_content = file_content; + inputs.file_diff = file_diff; + // reset chat session for each file while reviewing + next_review_ids = review_begin_ids; + if (file_content.length > 0) { + const file_content_tokens = get_token_count(file_content); + if (file_content_tokens < MAX_TOKENS_FOR_EXTRA_CONTENT) { + // review file + const [resp, review_file_ids] = await bot.chat(prompts.render_review_file(inputs), next_review_ids); + if (!resp) { + core.info('review: nothing obtained from chatgpt'); + } + else { + next_review_ids = review_file_ids; + } + } + else { + core.info(`skip sending content of file: ${inputs.filename} due to token count: ${file_content_tokens}`); + } + } + if (file_diff.length > 0) { + const file_diff_tokens = get_token_count(file_diff); + if (file_diff_tokens < MAX_TOKENS_FOR_EXTRA_CONTENT) { + // review diff + const [resp, review_diff_ids] = await bot.chat(prompts.render_review_file_diff(inputs), next_review_ids); + if (!resp) { + core.info('review: nothing obtained from chatgpt'); + } + else { + next_review_ids = review_diff_ids; + } + } + else { + core.info(`skip sending diff of file: ${inputs.filename} due to token count: ${file_diff_tokens}`); + } + } + // review_patch_begin + const [, patch_begin_ids] = await bot.chat(prompts.render_review_patch_begin(inputs), next_review_ids); + next_review_ids = patch_begin_ids; + for (const [line, patch] of patches) { + core.info(`Reviewing ${filename}:${line} with chatgpt ...`); + inputs.patch = patch; + const [response, patch_ids] = await bot.chat(prompts.render_review_patch(inputs), next_review_ids); + if (!response) { + core.info('review: nothing obtained from chatgpt'); + continue; + } + next_review_ids = patch_ids; + if (!options.review_comment_lgtm && response.includes('LGTM')) { + continue; + } + try { + await commenter.review_comment(review_context.payload.pull_request.number, commits[commits.length - 1].sha, filename, line, `${response}`); + } + catch (e) { + core.warning(`Failed to comment: ${e}, skipping. + backtrace: ${e.stack} + filename: ${filename} + line: ${line} + patch: ${patch}`); + } + } + } } }; -// Write a function that takes diff for a single file as a string +// Write a function that takes diff for a single file as a string // and splits the diff into separate patches const split_patch = (patch) => { if (!patch) { diff --git a/docs/images/ai.png b/docs/images/ai.png new file mode 100644 index 00000000..d9423dbb Binary files /dev/null and b/docs/images/ai.png differ diff --git a/package-lock.json b/package-lock.json index c255dadc..6408c691 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "@types/node": "^18.14.4", "@typescript-eslint/parser": "^5.54.0", "@vercel/ncc": "^0.36.1", - "chatgpt": "^5.0.9", + "chatgpt": "^5.0.10", "eslint": "^8.35.0", "eslint-plugin-github": "^4.6.1", "eslint-plugin-jest": "^25.3.2", @@ -3388,9 +3388,9 @@ } }, "node_modules/chatgpt": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/chatgpt/-/chatgpt-5.0.9.tgz", - "integrity": "sha512-H0MMegLKcYyYh3LeFO4ubIdJSiSAl4rRjTeXf3KjHfGXDM7QZ1EkiTH9RuIoaNzOm8rJTn4QEhrwBbOIpbalxw==", + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/chatgpt/-/chatgpt-5.0.10.tgz", + "integrity": "sha512-R3vtPlhCapFLkDXED0Cnt1DBcOZAXygr0M5U5kbSI0Fwm4uDQmc7qoIOnr17rd8eaa0JO/UDOevJdEWvd079qA==", "dev": true, "dependencies": { "@dqbd/tiktoken": "^0.4.0", @@ -13436,9 +13436,9 @@ "dev": true }, "chatgpt": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/chatgpt/-/chatgpt-5.0.9.tgz", - "integrity": "sha512-H0MMegLKcYyYh3LeFO4ubIdJSiSAl4rRjTeXf3KjHfGXDM7QZ1EkiTH9RuIoaNzOm8rJTn4QEhrwBbOIpbalxw==", + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/chatgpt/-/chatgpt-5.0.10.tgz", + "integrity": "sha512-R3vtPlhCapFLkDXED0Cnt1DBcOZAXygr0M5U5kbSI0Fwm4uDQmc7qoIOnr17rd8eaa0JO/UDOevJdEWvd079qA==", "dev": true, "requires": { "@dqbd/tiktoken": "^0.4.0", diff --git a/package.json b/package.json index 676c26e7..3759f6da 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "@types/node": "^18.14.4", "@typescript-eslint/parser": "^5.54.0", "@vercel/ncc": "^0.36.1", - "chatgpt": "^5.0.9", + "chatgpt": "^5.0.10", "eslint": "^8.35.0", "eslint-plugin-github": "^4.6.1", "eslint-plugin-jest": "^25.3.2", diff --git a/src/options.ts b/src/options.ts index 7277e4e4..669cde9c 100644 --- a/src/options.ts +++ b/src/options.ts @@ -75,6 +75,7 @@ export class Inputs { system_message: string title: string description: string + summary: string filename: string file_content: string file_diff: string @@ -85,6 +86,7 @@ export class Inputs { system_message = '', title = '', description = '', + summary = '', filename = '', file_content = '', file_diff = '', @@ -94,6 +96,7 @@ export class Inputs { this.system_message = system_message this.title = title this.description = description + this.summary = summary this.filename = filename this.file_content = file_content this.file_diff = file_diff @@ -114,6 +117,9 @@ export class Inputs { if (this.description) { content = content.replace('$description', this.description) } + if (this.summary) { + content = content.replace('$summary', this.summary) + } if (this.filename) { content = content.replace('$filename', this.filename) } @@ -147,7 +153,7 @@ export class Options { review_comment_lgtm = false, path_filters: string[] | null = null, system_message = '', - temperature = '0.2' + temperature = '0.0' ) { this.debug = debug this.chatgpt_reverse_proxy = chatgpt_reverse_proxy diff --git a/src/review.ts b/src/review.ts index e3bd9d3d..c0f6cbd0 100644 --- a/src/review.ts +++ b/src/review.ts @@ -117,17 +117,104 @@ export const codeReview = async ( if (files_to_review.length > 0) { const commenter: Commenter = new Commenter() - const [, review_begin_ids] = await bot.chat( - prompts.render_review_beginning(inputs), - {} - ) - let next_review_ids = review_begin_ids - + // Summary Stage const [, summarize_begin_ids] = await bot.chat( prompts.render_summarize_beginning(inputs), {} ) let next_summarize_ids = summarize_begin_ids + for (const [filename, file_content, file_diff] of files_to_review) { + inputs.filename = filename + inputs.file_content = file_content + inputs.file_diff = file_diff + if (file_diff.length > 0) { + // summarize diff + const [summarize_resp, summarize_diff_ids] = await bot.chat( + prompts.render_summarize_file_diff(inputs), + next_summarize_ids + ) + if (!summarize_resp) { + core.info('summarize: nothing obtained from chatgpt') + } else { + next_summarize_ids = summarize_diff_ids + } + } + } + // final summary + const [summarize_final_response, summarize_final_response_ids] = + await bot.chat(prompts.render_summarize(inputs), next_summarize_ids) + if (!summarize_final_response) { + core.info('summarize: nothing obtained from chatgpt') + } else { + inputs.summary = summarize_final_response + + next_summarize_ids = summarize_final_response_ids + const tag = + '' + await commenter.comment(`${summarize_final_response}`, tag, 'replace') + } + + // final release notes + const [release_notes_response, release_notes_ids] = await bot.chat( + prompts.render_summarize_release_notes(inputs), + next_summarize_ids + ) + if (!release_notes_response) { + core.info('release notes: nothing obtained from chatgpt') + } else { + next_summarize_ids = release_notes_ids + // add this response to the description field of the PR as release notes by looking + // for the tag (marker) + try { + const description = inputs.description + + // find the tag in the description and replace the content between the tag and the tag_end + // if not found, add the tag and the content to the end of the description + const tag_index = description.indexOf(description_tag) + const tag_end_index = description.indexOf(description_tag_end) + if (tag_index === -1 || tag_end_index === -1) { + let new_description = description + new_description += description_tag + new_description += '\n### Summary by ChatGPT\n' + new_description += release_notes_response + new_description += '\n' + new_description += description_tag_end + await octokit.pulls.update({ + owner: repo.owner, + repo: repo.repo, + pull_number: context.payload.pull_request.number, + body: new_description + }) + } else { + let new_description = description.substring(0, tag_index) + new_description += description_tag + new_description += '\n### Summary by ChatGPT\n' + new_description += release_notes_response + new_description += '\n' + new_description += description_tag_end + new_description += description.substring( + tag_end_index + description_tag_end.length + ) + await octokit.pulls.update({ + owner: repo.owner, + repo: repo.repo, + pull_number: context.payload.pull_request.number, + body: new_description + }) + } + } catch (e: any) { + core.warning( + `Failed to get PR: ${e}, skipping adding release notes to description.` + ) + } + } + + // Review Stage + const [, review_begin_ids] = await bot.chat( + prompts.render_review_beginning(inputs), + {} + ) + let next_review_ids = review_begin_ids for (const [ filename, @@ -175,17 +262,6 @@ export const codeReview = async ( } else { next_review_ids = review_diff_ids } - - // summarize diff - const [summarize_resp, summarize_diff_ids] = await bot.chat( - prompts.render_summarize_file_diff(inputs), - next_summarize_ids - ) - if (!summarize_resp) { - core.info('summarize: nothing obtained from chatgpt') - } else { - next_summarize_ids = summarize_diff_ids - } } else { core.info( `skip sending diff of file: ${inputs.filename} due to token count: ${file_diff_tokens}` @@ -232,76 +308,10 @@ export const codeReview = async ( } } } - // final summary - const [summarize_final_response, summarize_final_response_ids] = - await bot.chat(prompts.render_summarize(inputs), next_summarize_ids) - if (!summarize_final_response) { - core.info('summarize: nothing obtained from chatgpt') - } else { - next_summarize_ids = summarize_final_response_ids - const tag = - '' - await commenter.comment(`${summarize_final_response}`, tag, 'replace') - } - - // final release notes - const [release_notes_response, release_notes_ids] = await bot.chat( - prompts.render_summarize_release_notes(inputs), - next_summarize_ids - ) - if (!release_notes_response) { - core.info('release notes: nothing obtained from chatgpt') - } else { - next_summarize_ids = release_notes_ids - // add this response to the description field of the PR as release notes by looking - // for the tag (marker) - try { - const description = inputs.description - - // find the tag in the description and replace the content between the tag and the tag_end - // if not found, add the tag and the content to the end of the description - const tag_index = description.indexOf(description_tag) - const tag_end_index = description.indexOf(description_tag_end) - if (tag_index === -1 || tag_end_index === -1) { - let new_description = description - new_description += description_tag - new_description += '\n### Summary by ChatGPT\n' - new_description += release_notes_response - new_description += '\n' - new_description += description_tag_end - await octokit.pulls.update({ - owner: repo.owner, - repo: repo.repo, - pull_number: context.payload.pull_request.number, - body: new_description - }) - } else { - let new_description = description.substring(0, tag_index) - new_description += description_tag - new_description += '\n### Summary by ChatGPT\n' - new_description += release_notes_response - new_description += '\n' - new_description += description_tag_end - new_description += description.substring( - tag_end_index + description_tag_end.length - ) - await octokit.pulls.update({ - owner: repo.owner, - repo: repo.repo, - pull_number: context.payload.pull_request.number, - body: new_description - }) - } - } catch (e: any) { - core.warning( - `Failed to get PR: ${e}, skipping adding release notes to description.` - ) - } - } } } -// Write a function that takes diff for a single file as a string +// Write a function that takes diff for a single file as a string // and splits the diff into separate patches const split_patch = (patch: string | null | undefined): string[] => {