Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@
"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",
"baller:build": "ncc build src/baller/main.ts --out dist/baller --source-map --license licenses.txt",
"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"
},
Expand Down
6 changes: 5 additions & 1 deletion src/baller/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
})

Expand Down
4 changes: 2 additions & 2 deletions src/lib/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ const handleResponse = async (response: Response): Promise<any> => {
}
}

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'
},
Expand Down
9 changes: 9 additions & 0 deletions src/lib/github/pulls/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,12 @@ export const label = async (params: {
}, {} as Record<string, any>)
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<string, any>)
)
8 changes: 8 additions & 0 deletions src/lib/jobs/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,11 @@ export const create = ({
url: string
access_token: string
}): Promise<Job> => 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))
1 change: 0 additions & 1 deletion src/lib/jobs/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
export * from './types'
export * from './utils'
export * from './api'
export * from './messages'
13 changes: 13 additions & 0 deletions src/lib/jobs/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export type Job = {
error: any
repository?: Repository
contribution?: Contribution
comment?: Comment
}

export type Repository = {
Expand All @@ -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
Expand All @@ -46,3 +54,8 @@ export type Contribution = {
merged_at: any
result: 'inconclusive' | 'approved' | 'not_approved' | null
}

export type Comment = {
url: string
suggestions: Suggestion[]
}
16 changes: 12 additions & 4 deletions src/status/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> => {
const run = async (
jobID: string
): Promise<{isApproved: boolean; isSuggested: boolean}> => {
core.info(`Job ID: ${jobID}`)

let job = await get(jobID)
Expand All @@ -22,15 +29,16 @@ const run = async (jobID: string): Promise<boolean> => {
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) {
Expand Down
158 changes: 158 additions & 0 deletions src/suggester/main.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
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)
}
})
6 changes: 5 additions & 1 deletion status/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
If the Codeball approved the contribution (true or false)

## `suggested`

If the Codeball has suggestions for the contribution (true or false)
2 changes: 2 additions & 0 deletions status/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
19 changes: 19 additions & 0 deletions suggester/README.md
Original file line number Diff line number Diff line change
@@ -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_
19 changes: 19 additions & 0 deletions suggester/action.yml
Original file line number Diff line number Diff line change
@@ -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'