From 5a391d2e00d6c91e3bca9e156ec59d3fe36f35dd Mon Sep 17 00:00:00 2001 From: Nikita Galaiko Date: Thu, 11 Aug 2022 14:45:50 +0200 Subject: [PATCH] suggester: init --- package.json | 3 +- src/baller/main.ts | 6 +- src/lib/api/index.ts | 4 +- src/lib/github/pulls/index.ts | 9 ++ src/lib/jobs/api.ts | 8 ++ src/lib/jobs/index.ts | 1 - src/lib/jobs/types.ts | 13 +++ src/status/main.ts | 16 +++- src/suggester/main.ts | 158 ++++++++++++++++++++++++++++++++++ status/README.md | 6 +- status/action.yml | 2 + suggester/README.md | 19 ++++ suggester/action.yml | 19 ++++ 13 files changed, 254 insertions(+), 10 deletions(-) create mode 100644 src/suggester/main.ts create mode 100644 suggester/README.md create mode 100644 suggester/action.yml diff --git a/package.json b/package.json index 3603543..67f92e5 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "", "main": "lib/main.js", "scripts": { - "build": "yarn baller:build && yarn approver:build && yarn status:build && yarn labeler:build", + "build": "yarn baller:build && yarn approver:build && yarn status:build && yarn labeler:build && yarn suggester:build", "format": "prettier --write '**/*.ts'", "format-check": "prettier --check '**/*.ts'", "lint": "eslint src/**/*.ts", @@ -12,6 +12,7 @@ "approver:build": "ncc build src/approver/main.ts --out dist/approver --source-map --license licenses.txt", "status:build": "ncc build src/status/main.ts --out dist/status --source-map --license licenses.txt", "labeler:build": "ncc build src/labeler/main.ts --out dist/labeler --source-map --license licenses.txt", + "suggester:build": "ncc build src/suggester/main.ts --out dist/suggester --source-map --license licenses.txt", "test": "jest", "all": "yarn format && yarn lint && yarn build" }, diff --git a/src/baller/main.ts b/src/baller/main.ts index 853cd26..064208b 100644 --- a/src/baller/main.ts +++ b/src/baller/main.ts @@ -7,13 +7,17 @@ async function run(): Promise<{jobId: string}> { const pullRequestURL = github.context.payload?.pull_request?.html_url if (!pullRequestURL) throw new Error('No pull request URL found') + const commentURL = github.context.payload?.comment?.html_url + const githubToken = core.getInput('GITHUB_TOKEN') if (!githubToken) throw new Error('No GitHub token found') core.info(`Found contribution: ${pullRequestURL}`) + if (commentURL) core.info(`Found comment: ${commentURL}`) const job = await create({ - url: pullRequestURL, + // if commentURL is present, we are in the context of a comment action, so trigger that. + url: commentURL ?? pullRequestURL, access_token: githubToken }) diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index f0c578f..c125c68 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -37,8 +37,8 @@ const handleResponse = async (response: Response): Promise => { } } -export const get = async (path: string) => - fetch(new URL(path, BASE_URL).toString(), { +export const get = async (path: string, params = new URLSearchParams()) => + fetch(new URL(path, BASE_URL).toString() + `?${params.toString()}}`, { headers: { 'User-Agent': 'github-actions' }, diff --git a/src/lib/github/pulls/index.ts b/src/lib/github/pulls/index.ts index 9f50acf..6c1c847 100644 --- a/src/lib/github/pulls/index.ts +++ b/src/lib/github/pulls/index.ts @@ -26,3 +26,12 @@ export const label = async (params: { }, {} as Record) return post('/github/pulls/label', body) } + +export const suggest = async (params: {link: string}) => + post( + '/github/pulls/suggest', + Object.entries(params).reduce((acc, [key, value]) => { + acc[key] = value + return acc + }, {} as Record) + ) diff --git a/src/lib/jobs/api.ts b/src/lib/jobs/api.ts index c4cb738..26ff505 100644 --- a/src/lib/jobs/api.ts +++ b/src/lib/jobs/api.ts @@ -10,3 +10,11 @@ export const create = ({ url: string access_token: string }): Promise => post('/jobs', {url, access_token}) + +export const list = (params: { + organization?: string + repository?: string + onlyRootJobs?: string + limit?: string +}): Promise<{jobs: Job[]; next?: string}> => + apiGET('/jobs', new URLSearchParams(params)) diff --git a/src/lib/jobs/index.ts b/src/lib/jobs/index.ts index 8e83d4c..e98d979 100644 --- a/src/lib/jobs/index.ts +++ b/src/lib/jobs/index.ts @@ -1,4 +1,3 @@ export * from './types' export * from './utils' export * from './api' -export * from './messages' diff --git a/src/lib/jobs/types.ts b/src/lib/jobs/types.ts index 1f74caf..b82e8cb 100644 --- a/src/lib/jobs/types.ts +++ b/src/lib/jobs/types.ts @@ -14,6 +14,7 @@ export type Job = { error: any repository?: Repository contribution?: Contribution + comment?: Comment } export type Repository = { @@ -22,6 +23,13 @@ export type Repository = { contribution_jobs: ContributionJob[] } +export type Suggestion = { + commit_id: string + filename: string + from_line: number + text: string +} + export type ContributionJob = { id: string parent: string @@ -46,3 +54,8 @@ export type Contribution = { merged_at: any result: 'inconclusive' | 'approved' | 'not_approved' | null } + +export type Comment = { + url: string + suggestions: Suggestion[] +} diff --git a/src/status/main.ts b/src/status/main.ts index 0b56989..e0de212 100644 --- a/src/status/main.ts +++ b/src/status/main.ts @@ -2,12 +2,19 @@ import {isContributionJob, isFinalStatus, get, required, Job} from '../lib' import * as core from '@actions/core' import {track} from '../lib/track' +const isSuggested = (job: Job) => + isFinalStatus(job.status) && + !!job.comment && + job.comment?.suggestions?.length > 0 + const isApproved = (job: Job): boolean => isFinalStatus(job.status) && isContributionJob(job) && job.contribution?.result === 'approved' -const run = async (jobID: string): Promise => { +const run = async ( + jobID: string +): Promise<{isApproved: boolean; isSuggested: boolean}> => { core.info(`Job ID: ${jobID}`) let job = await get(jobID) @@ -22,15 +29,16 @@ const run = async (jobID: string): Promise => { job = await get(jobID) } - return isApproved(job) + return {isApproved: isApproved(job), isSuggested: isSuggested(job)} } const jobID = required('codeball-job-id') run(jobID) - .then(async approved => { + .then(async ({isApproved, isSuggested}) => { await track({jobID, actionName: 'status'}) - core.setOutput('approved', approved) + core.setOutput('approved', isApproved) + core.setOutput('suggested', isSuggested) }) .catch(async error => { if (error instanceof Error) { diff --git a/src/suggester/main.ts b/src/suggester/main.ts new file mode 100644 index 0000000..5943217 --- /dev/null +++ b/src/suggester/main.ts @@ -0,0 +1,158 @@ +import * as core from '@actions/core' +import * as github from '@actions/github' +import {track} from '../lib/track' +import {suggest} from '../lib/github' +import {get} from '../lib/jobs' +import {Octokit, required} from '../lib' +import {ForbiddenError} from '../lib/api' + +const jobID = required('codeball-job-id') +const githubToken = required('GITHUB_TOKEN') +const octokit = new Octokit({auth: githubToken}) + +const run = async (): Promise => { + const pullRequestURL = github.context.payload?.pull_request?.html_url + if (!pullRequestURL) throw new Error('No pull request URL found') + + const pullRequestNumber = github.context.payload?.pull_request?.number + if (!pullRequestNumber) throw new Error('No pull request number found') + + const repoOwner = github.context.payload.repository?.owner.login + if (!repoOwner) throw new Error('No repo owner found') + + const repoName = github.context.payload.repository?.name + if (!repoName) throw new Error('No repo name found') + + github.context.payload.comment?.id + + const pr = await octokit.pulls + .get({ + owner: repoOwner, + repo: repoName, + pull_number: pullRequestNumber + }) + .then(r => r.data) + + const isPrivate = pr.base.repo.private + const isFromFork = pr.head.repo?.fork + const isToFork = pr.base.repo.fork + + suggestViaGitHub({ + owner: repoOwner, + repo: repoName, + pull_number: pullRequestNumber + }).catch(async error => { + if ( + error instanceof Error && + error.message === 'Resource not accessible by integration' + ) { + return suggestViaAPI({link: pullRequestURL}).catch(error => { + if (error.name === ForbiddenError.name) { + throw new Error( + !isPrivate && isFromFork && !isToFork + ? 'Codeball Suggester failed to access GitHub. Install https://github.com/apps/codeball-ai-writer to the base repository to give Codeball permission to comment on Pull Requests.' + : 'Codeball Suggester failed to access GitHub. Check the "GITHUB_TOKEN Permissions" of this job and make sure that the job has WRITE permissions to Pull Requests.' + ) + } + throw error + }) + } else { + throw error + } + }) +} + +const count = (str: string, substr: string) => str.split(substr).length - 1 + +const suggestViaGitHub = async ({ + owner, + repo, + pull_number +}: { + owner: string + repo: string + pull_number: number +}) => + get(jobID).then(async job => { + let suggestions = job?.comment?.suggestions + if (!suggestions) return + if (suggestions.length === 0) return + + const existingComments = await octokit.pulls + .listReviewComments({ + owner, + repo, + pull_number + }) + .then(r => r.data) + + // filter out already posted suggestions + suggestions = suggestions.filter( + suggestion => + !existingComments.find(comment => comment.body === suggestion.text) + ) + + suggestions.forEach(suggestion => { + const request = { + owner, + repo, + pull_number, + commit_id: suggestion.commit_id, + body: '```suggestion\n' + suggestion.text + '\n```', + path: suggestion.filename + } as { + owner: string + repo: string + pull_number: number + commit_id: string + body: string + path: string + start_line?: number + side?: 'LEFT' | 'RIGHT' + line?: number + start_side?: 'LEFT' | 'RIGHT' + } + + if (count(suggestion.text, '\n') > 1) { + request.start_line = suggestion.from_line + request.start_side = 'RIGHT' + request.line = suggestion.from_line + count(suggestion.text, '\n') + request.side = 'RIGHT' + } else { + request.line = suggestion.from_line + request.side = 'RIGHT' + } + + const inReplyTo = existingComments.find(comment => { + if (!comment.line) return false + const isSuggestionMultiline = suggestion.text.indexOf('\n') > -1 + const isCommentMultiline = comment.body.indexOf('\n') > -1 + if (!isSuggestionMultiline && !isCommentMultiline) + return comment.line === suggestion.from_line + return false + }) + + if (inReplyTo) { + octokit.pulls.createReplyForReviewComment({ + owner, + repo, + pull_number, + body: request.body, + comment_id: inReplyTo.id + }) + } else { + octokit.pulls.createReviewComment(request) + } + }) + }) + +const suggestViaAPI = ({link}: {link: string}) => suggest({link}) + +run() + .then(async () => await track({jobID, actionName: 'suggester'})) + .catch(async error => { + if (error instanceof Error) { + await track({jobID, actionName: 'suggester', error: error.message}) + core.setFailed(error.message) + } + }) diff --git a/status/README.md b/status/README.md index be78e50..ab0bf07 100644 --- a/status/README.md +++ b/status/README.md @@ -12,4 +12,8 @@ The ID of the Codeball Job created by the Baller Action ## `approved` -If the Codeball approved the contribution (true or false) \ No newline at end of file +If the Codeball approved the contribution (true or false) + +## `suggested` + +If the Codeball has suggestions for the contribution (true or false) diff --git a/status/action.yml b/status/action.yml index daf5a8f..871fc85 100644 --- a/status/action.yml +++ b/status/action.yml @@ -13,6 +13,8 @@ inputs: outputs: approved: description: 'If the Codeball approved the contribution (true or false)' + suggested: + description: 'If the Codeball has suggestions for the contribution (true or false)' runs: using: 'node16' diff --git a/suggester/README.md b/suggester/README.md new file mode 100644 index 0000000..8d58049 --- /dev/null +++ b/suggester/README.md @@ -0,0 +1,19 @@ +# Codeball Suggester + +This action adds suggestions on the Pull Request. + +# Inputs + +## `codeball-job-id` (optional) + +The ID of the Codeball Job created by the Baller Action + +## `GITHUB_TOKEN` (optional) + +Default to {{ github.token }}. This is the default GitHub token available to actions and is used to run Codeball and to post the result. The default token permissions (https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions#permissions) work fine. + +Default: `'${{ github.token }}'` + +# Outputs + +_This action has no outputs_ diff --git a/suggester/action.yml b/suggester/action.yml new file mode 100644 index 0000000..1b1778b --- /dev/null +++ b/suggester/action.yml @@ -0,0 +1,19 @@ +name: 'Codeball Suggester' +description: 'Codeball Suggester (beta)' + +branding: + icon: check + color: orange + +inputs: + GITHUB_TOKEN: + description: 'Default to {{ github.token }}. This is the default GitHub token available to actions and is used to run Codeball and to post the result. The default token permissions (https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions#permissions) work fine.' + default: '${{ github.token }}' + required: false + codeball-job-id: + description: 'The ID of the Codeball Job created by the Baller Action' + required: true + +runs: + using: 'node16' + main: '../dist/suggester/index.js'