diff --git a/src/__tests__/run.test.ts b/src/__tests__/run.test.ts index 2819b82..7be26e9 100644 --- a/src/__tests__/run.test.ts +++ b/src/__tests__/run.test.ts @@ -1,19 +1,20 @@ import { mockdata } from '../mockdata' import { run } from '../index' import * as github from '@actions/github' -import { - getJiraTicket, - getChanges, - SummariseChanges, - postSummary -} from '../steps' +import { getChanges, SummarizeChanges } from '../steps' import * as core from '@actions/core' +import { JiraClient } from '../clients' jest.mock('@actions/github') jest.mock('./services') jest.mock('@actions/core') describe('run', () => { + const jira = jest.fn(() => { + return { + getJiraTicket: jest.fn(() => ['JIRA-123']) + } + }) it('should execute without errors', async () => { const jiraIssues = ['JIRA-123'] const changes = ['Change 1', 'Change 2'] @@ -22,12 +23,6 @@ describe('run', () => { const acsummaries = 'Summary' const githubContext = mockdata - // getJiraTicket.mockResolvedValue(jiraIssues) - // getChanges.mockResolvedValue(changes) - // SummariseChanges.summarizeGitChanges.mockResolvedValue(gitSummary) - // SummariseChanges.summariseJiraTickets.mockResolvedValue(jiraSummary) - // SummariseChanges.checkedCodeReviewAgainstCriteria.mockResolvedValue(acsummaries) - // postComment.mockResolvedValue() // await run() @@ -37,11 +32,20 @@ describe('run', () => { // body: githubContext.payload.pull_request.body // }) - // expect(getChanges).toHaveBeenCalledWith(githubContext.payload.pull_request.number) + // expect(getChanges).toHaveBeenCalledWith( + // githubContext.payload.pull_request.number + // ) // expect(SummariseChanges.summarizeGitChanges).toHaveBeenCalledWith(changes) - // expect(SummariseChanges.summariseJiraTickets).toHaveBeenCalledWith(jiraIssues) - // expect(SummariseChanges.checkedCodeReviewAgainstCriteria).toHaveBeenCalledWith(gitSummary, jiraSummary) - // expect(postComment).toHaveBeenCalledWith(githubContext.payload.pull_request.number, gitSummary) + // expect(SummariseChanges.summariseJiraTickets).toHaveBeenCalledWith( + // jiraIssues + // ) + // expect( + // SummariseChanges.checkedCodeReviewAgainstCriteria + // ).toHaveBeenCalledWith(gitSummary, jiraSummary) + // expect(postComment).toHaveBeenCalledWith( + // githubContext.payload.pull_request.number, + // gitSummary + // ) }) it('should handle errors', async () => { diff --git a/src/ai.ts b/src/ai.ts new file mode 100644 index 0000000..79a564d --- /dev/null +++ b/src/ai.ts @@ -0,0 +1,49 @@ +import { + prompt, + jiraPrompt, + acSummariesPrompt, + compareOldSummaryTemplate +} from './constants.js' +import * as core from '@actions/core' +import OpenAI from 'openai' +import { Logger } from './utils.js' + +export class Ai { + constructor() { + const openAiKey = core.getInput('openAIKey') || process.env.OPENAI_API_KEY + if (!openAiKey) { + throw new Error('OpenAI key is required') + } + this.model = new OpenAI({ + apiKey: openAiKey + }) + } + configuration = { + model: 'gpt-3.5-turbo' + } + model: OpenAI + basePromptTemplate = prompt + jiraPromptTemplate = jiraPrompt + acSummariesPromptTemplate = acSummariesPrompt + compareOldSummaryTemplate(oldSummary: string, newSummary: string): string { + return compareOldSummaryTemplate(oldSummary, newSummary) + } + execute = async (prompt: string) => { + try { + const response = await this.model.chat.completions.create({ + messages: [ + { + role: 'user', + content: prompt + } + ], + ...this.configuration + }) + Logger.log('ai response', { response }) + return response.choices[0].message.content + } catch (e) { + Logger.error('error summarizing changes', e) + return null + } + } +} diff --git a/src/clients/github.ts b/src/clients/github.ts new file mode 100644 index 0000000..7f26c5e --- /dev/null +++ b/src/clients/github.ts @@ -0,0 +1,48 @@ +import * as core from '@actions/core' +import * as github from '@actions/github' +import { Logger } from '../utils' +import { Ai } from '../ai' +import { GitHub } from '@actions/github/lib/utils' + +export class GithubClient { + octokit: InstanceType + repo: { owner: string; repo: string } + constructor() { + const { octokit, repo } = this.getGithubContext() + this.octokit = octokit + this.repo = repo + } + + getGithubContext = () => { + const githubToken = + core.getInput('gitHubToken') || process.env.GITHUB_ACCESS_TOKEN || '' + const octokit = github.getOctokit(githubToken) + const repo = github.context.repo + return { octokit, repo, githubToken } + } + + async getComments(pullRequestNumber: number) { + try { + const response = await this.octokit.rest.issues.listComments({ + ...this.repo, + issue_number: pullRequestNumber + }) + return response.data + } catch (error) { + Logger.error('error getting comments', JSON.stringify(error)) + return [] + } + } + + async postComment(comment: string, pullRequestNumber: number) { + try { + await this.octokit.rest.pulls.update({ + ...this.repo, + pull_number: pullRequestNumber, + body: comment + }) + } catch (error) { + Logger.error('error posting comment', JSON.stringify(error)) + } + } +} diff --git a/src/clients/index.ts b/src/clients/index.ts new file mode 100644 index 0000000..66e9306 --- /dev/null +++ b/src/clients/index.ts @@ -0,0 +1,2 @@ +export * from './jira.js' +export * from './github.js' diff --git a/src/clients/jira.ts b/src/clients/jira.ts new file mode 100644 index 0000000..9561328 --- /dev/null +++ b/src/clients/jira.ts @@ -0,0 +1,55 @@ +import { WebhookPayload } from '@actions/github/lib/interfaces' +import { Version2Client } from 'jira.js' +import { Issue } from 'jira.js/out/agile/models' +import * as core from '@actions/core' + +import { Logger } from '../utils' + +export class JiraClient { + client: Version2Client + constructor() { + this.client = this.initializeJiraClient() + } + initializeJiraClient = () => { + const host = core.getInput('jiraHost') || process.env.JIRA_HOST || '' + return new Version2Client({ + host, + authentication: { + basic: { + email: core.getInput('jiraEmail') || process.env.JIRA_EMAIL || '', + apiToken: + core.getInput('jiraApiKey') || process.env.JIRA_API_KEY || '' + } + } + }) + } + getJiraTicket = async ({ + title, + branchName, + body + }: { + title?: string + branchName: string + body?: string + }): Promise => { + const ticketRegex = /([A-Z]+-[0-9]+)/g + const allTickets = (`${body} ${branchName} ${title}` || '').match( + ticketRegex + ) + if (!allTickets?.length) return [] + const ticket = [...new Set(allTickets)] + const issues = await Promise.all( + ticket.map(async t => { + try { + const issue = await this.client.issues.getIssue({ + issueIdOrKey: t + }) + return issue.fields.description + } catch (e) { + Logger.error(`Error while fetching ${t} from JIRA`) + } + }) + ) + return issues.filter(e => e) as unknown as Issue[] + } +} diff --git a/src/prompts.ts b/src/constants.ts similarity index 97% rename from src/prompts.ts rename to src/constants.ts index 69482b0..3e9fad1 100644 --- a/src/prompts.ts +++ b/src/constants.ts @@ -55,3 +55,6 @@ if the acceptance criteria has been met in the old summary but not in the new su keep the boxes checked if the acceptance criteria has been met in both summaries keep the information updated based on the new summary of the code changes ` + +// using regex to match the file extensions to ignore +export const ignoredFiles = [] diff --git a/src/index.ts b/src/index.ts index b7ae398..5c16dad 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,81 +1,78 @@ import * as core from '@actions/core' import * as github from '@actions/github' -import { - SummariseChanges, - getChanges, - postSummary, - getJiraTicket, - Ai, - postComment -} from './steps' +import { SummarizeChanges, getChanges, CommentHandler } from './steps' import dotenv from 'dotenv' -dotenv.config({ path: __dirname + '/.env' }) +dotenv.config() -import { Logger } from './utils.js' +import { Logger, Templates } from './utils.js' +import { Ai } from './ai' +import { GithubClient, JiraClient } from './clients' import { mockdata } from './mockdata' +// instantiate clients +const jiraClient = new JiraClient() +const githubClient = new GithubClient() +const commentsHandler = new CommentHandler(githubClient) +const ai = new Ai() + export async function run(): Promise { try { - // const githubContext = mockdata - const githubContext = github.context + const githubContext = + process.env.NODE_ENV === 'local' ? mockdata : github.context const pullRequestNumber = githubContext.payload.pull_request?.number if (!pullRequestNumber) { Logger.warn('Could not get pull request number from context, exiting') return } - const jiraIssues = await getJiraTicket({ + + const jiraIssues = await jiraClient.getJiraTicket({ title: githubContext.payload.pull_request?.title, branchName: githubContext.payload.pull_request?.head.ref, body: `${githubContext.payload.pull_request?.body} ${githubContext.payload.pull_request?.head.ref}}` }) + if (!jiraIssues.length) { Logger.warn('Could not get jira ticket, exiting') - await postComment( - ` - **⚠️ Warning:** - No jira ticket found. - `, + await commentsHandler.postComment( + Templates.warning( + 'No jira ticket found in this pull request, exiting.' + ), pullRequestNumber ) return } + const changes = await getChanges(pullRequestNumber) if (!changes) { Logger.warn('Could not get changes, exiting') - await postComment( - ` - **⚠️ Warning:** - No git changes found in this pull request. - `, + await commentsHandler.postComment( + Templates.warning('No git changes found in this pull request.'), pullRequestNumber ) - return } - const ai = new Ai() - const gitSummary = await SummariseChanges.summarizeGitChanges(changes, ai) - const jiraSummary = await SummariseChanges.summariseJiraTickets( - jiraIssues, - ai - ) + const [gitSummary, jiraSummary] = await Promise.all([ + SummarizeChanges.summarizeGitChanges(changes, ai), + SummarizeChanges.summarizeJiraTickets(jiraIssues, ai) + ]) + if (!jiraSummary || !gitSummary) { - Logger.warn('Summary is empty, exiting') - await postComment( - ` - **⚠️ Warning:** - No jira ticket found. - `, + Logger.warn('No jira ticket found or Summary is empty, exiting') + await commentsHandler.postComment( + Templates.warning('No matching jira ticket found.'), pullRequestNumber ) return } - const acSummaries = await SummariseChanges.checkedCodeReviewAgainstCriteria( + + const acSummaries = await SummarizeChanges.checkedCodeReviewAgainstCriteria( gitSummary, jiraSummary, ai ) - await postSummary(pullRequestNumber, acSummaries ?? '', ai) + + await commentsHandler.postSummary(pullRequestNumber, acSummaries ?? '', ai) } catch (error) { core.setFailed((error as Error)?.message as string) } diff --git a/src/steps/comments-handler.ts b/src/steps/comments-handler.ts new file mode 100644 index 0000000..5890e21 --- /dev/null +++ b/src/steps/comments-handler.ts @@ -0,0 +1,31 @@ +import * as core from '@actions/core' +import * as github from '@actions/github' +import { Logger } from '../utils' +import { Ai } from '../ai' +import { GitHub } from '@actions/github/lib/utils' +import { GithubClient } from '../clients' + +export class CommentHandler { + constructor(private readonly repoClient: GithubClient) {} + SIGNATURE = 'Added by woot! 🚂' + async postSummary(pullRequestNumber: number, summary: string, ai: Ai) { + Logger.log('posted comment', github.context) + const comments = await this.repoClient.getComments(pullRequestNumber) + const existingComment = comments.find( + comment => comment.body?.includes(this.SIGNATURE) + ) + let comment = `${summary} \n ${this.SIGNATURE}` + if (existingComment?.body) { + Logger.log('found existing comment, updating') + comment = `${await ai.compareOldSummaryTemplate( + existingComment.body, + summary + )} \n ${this.SIGNATURE}` + } + await this.postComment(comment, pullRequestNumber) + } + + postComment = async (comment: string, pullRequestNumber: number) => { + return this.repoClient.postComment(comment, pullRequestNumber) + } +} diff --git a/src/steps/get-changes.ts b/src/steps/get-changes.ts index bac8bd5..1c115c0 100644 --- a/src/steps/get-changes.ts +++ b/src/steps/get-changes.ts @@ -1,7 +1,6 @@ import * as core from '@actions/core' import * as github from '@actions/github' import { Logger } from '../utils' -// import { mockdata } from '../mockdata' export async function getChanges( pullRequestNumber: number diff --git a/src/steps/index.ts b/src/steps/index.ts index 17a72c1..a25b333 100644 --- a/src/steps/index.ts +++ b/src/steps/index.ts @@ -1,4 +1,3 @@ export * from './get-changes.js' -export * from './post-comment.js' +export * from './comments-handler.js' export * from './summarize-changes.js' -export * from './jira.js' diff --git a/src/steps/jira.ts b/src/steps/jira.ts deleted file mode 100644 index cbaaf17..0000000 --- a/src/steps/jira.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { WebhookPayload } from '@actions/github/lib/interfaces' -import { Version2Client } from 'jira.js' -import { Issue } from 'jira.js/out/agile/models' -import * as core from '@actions/core' - -import { Logger } from '../utils' - -const initializeJiraClient = () => { - const host = core.getInput('jiraHost') || process.env.JIRA_HOST || '' - return new Version2Client({ - host, - authentication: { - basic: { - email: core.getInput('jiraEmail') || process.env.JIRA_EMAIL || '', - apiToken: core.getInput('jiraApiKey') || process.env.JIRA_API_KEY || '' - } - } - }) -} - -export const getJiraTicket = async ({ - title, - branchName, - body -}: { - title?: string - branchName: string - body?: string -}): Promise => { - const jiraClient = initializeJiraClient() - const ticketRegex = /([A-Z]+-[0-9]+)/g - const allTickets = (`${body} ${branchName} ${title}` || '').match(ticketRegex) - if (!allTickets?.length) return [] - const ticket = [...new Set(allTickets)] - const issues = await Promise.all( - ticket.map(async t => { - try { - const issue = await jiraClient.issues.getIssue({ - issueIdOrKey: t - }) - return issue.fields.description - } catch (e) { - Logger.error(`Error while fetching ${t} from JIRA`) - } - }) - ) - return issues.filter(e => e) as unknown as Issue[] -} diff --git a/src/steps/post-comment.ts b/src/steps/post-comment.ts deleted file mode 100644 index b4802e9..0000000 --- a/src/steps/post-comment.ts +++ /dev/null @@ -1,53 +0,0 @@ -import * as core from '@actions/core' -import * as github from '@actions/github' -import { Logger } from '../utils' -import { Ai } from '.' - -const SIGNATURE = 'Added by woot! 🚂' -const getGithubContext = () => { - const githubToken = - core.getInput('gitHubToken') || process.env.GITHUB_ACCESS_TOKEN || '' - const octokit = github.getOctokit(githubToken) - const repo = github.context.repo - return { githubToken, octokit, repo } -} - -export async function postSummary( - pullRequestNumber: number, - summary: string, - ai: Ai -) { - Logger.log('posted comment', github.context) - - const { octokit, repo } = getGithubContext() - const { data: comments } = await octokit.rest.issues.listComments({ - ...repo, - issue_number: pullRequestNumber - }) - - const existingComment = comments.find( - comment => comment.body?.includes(SIGNATURE) - ) - let comment = ` - ${summary} - ${SIGNATURE} - ` - if (existingComment?.body) { - Logger.log('found existing comment, updating') - comment = `${await ai.compareOldSummaryTemplate( - existingComment.body, - summary - )} ${SIGNATURE}` - } - - postComment(comment, pullRequestNumber) -} - -export const postComment = async (comment:string, pullRequestNumber:number) => { - const { octokit, repo } = getGithubContext() - await octokit.rest.pulls.update({ - ...repo, - pull_number: pullRequestNumber, - body: comment - }) -} \ No newline at end of file diff --git a/src/steps/summarize-changes.ts b/src/steps/summarize-changes.ts index cc728c7..a0844f2 100644 --- a/src/steps/summarize-changes.ts +++ b/src/steps/summarize-changes.ts @@ -1,115 +1,35 @@ -import OpenAI from 'openai' -const { RecursiveCharacterTextSplitter } = require('langchain/text_splitter') -import { - prompt, - jiraPrompt, - acSummariesPrompt, - compareOldSummaryTemplate -} from '../prompts.js' +import { Ai } from '../ai.js' import { Logger } from '../utils.js' -import * as core from '@actions/core' import { Issue } from 'jira.js/out/agile/models' -export class Ai { - constructor() { - const openAiKey = - core.getInput('openAIKey') || process.env.OPENAI_API_KEY || '' - this.model = new OpenAI({ - apiKey: openAiKey - }) - } - configuration = { - model: 'gpt-3.5-turbo' - } - model: OpenAI - basePromptTemplate = prompt - jiraPromptTemplate = jiraPrompt - acSummariesPromptTemplate = acSummariesPrompt - compareOldSummaryTemplate(oldSummary: string, newSummary: string): string { - return compareOldSummaryTemplate(oldSummary, newSummary) - } -} - -export class SummariseChanges { - static textSplitter = new RecursiveCharacterTextSplitter({ - chunkOverlap: 0, - keepSeparator: true, - chunkSize: 5000 - }) +export class SummarizeChanges { static async summarizeGitChanges( diff: string, ai: Ai ): Promise { - try { - // lovely approach but takes too long since we can have 30 - 50 - // documents and cannot wait the entire time - // const docs = await this.textSplitter.createDocuments([diff]) - const response = await ai.model.chat.completions.create({ - messages: [ - { - role: 'user', - content: `${ai.basePromptTemplate} - diff: ${diff}` - } - ], - ...ai.configuration - }) - Logger.log('summarized changes', { response }) - return response.choices[0].message.content - } catch (e) { - Logger.error('error summarizing changes', e) - return null - } + Logger.log('fetching summarized changes') + return ai.execute(`${ai.basePromptTemplate} \n diff: ${diff}`) } - static async summariseJiraTickets(issues: Issue[], ai: Ai) { + static async summarizeJiraTickets( + issues: Issue[], + ai: Ai + ): Promise { const issueMapLongDesc = issues.join('\n') - try { - // const docs = await this.textSplitter.createDocuments([issueMapLongDesc]) - const response = await ai.model.chat.completions.create({ - messages: [ - { - role: 'user', - content: `${ai.jiraPromptTemplate} - _____________________________ - ${issueMapLongDesc}` - } - ], - ...ai.configuration - }) - Logger.log('summarized jira tickets', { response }) - return response.choices[0].message.content - } catch (e) { - Logger.error('error summarizing changes', e) - } + Logger.log('summarizing jira tickets') + return ai.execute( + `${ai.jiraPromptTemplate} \n _____________________________ \n ${issueMapLongDesc}` + ) } static checkedCodeReviewAgainstCriteria = async ( gitSummary: string, jiraSummary: string, ai: Ai - ) => { - try { - // decided to use a custom prompt for this - const response = await ai.model.chat.completions.create({ - messages: [ - { - role: 'user', - content: ` - ${ai.acSummariesPromptTemplate} - ------------------ git diff summary ------------------ - ${gitSummary} - ------------------ jira tickets summary ------------------ - ${jiraSummary} - ` - } - ], - ...ai.configuration - }) - console.log(response) - return response.choices[0].message.content - } catch (e) { - Logger.error('error summarizing changes', e) - } + ): Promise => { + Logger.log('checking code review against criteria') + return ai.execute( + `${ai.acSummariesPromptTemplate} \n ------------------ git diff summary ------------------ \n ${gitSummary} \n ------------------ jira tickets summary ------------------ \n ${jiraSummary}` + ) } } diff --git a/src/types.ts b/src/types.ts deleted file mode 100644 index e69de29..0000000 diff --git a/src/utils.ts b/src/utils.ts index bd965dc..7ce810f 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -11,7 +11,17 @@ export class Logger { } static error(message: string, ...args: any[]) { + // if process.env is not local then don't log the error const formattedArgs = args.map(arg => JSON.stringify(arg)).join(' ') core.error(`${message}: ${formattedArgs}`) } } + +export class Templates { + static warning(message: string) { + return ` + **⚠️ Warning:** + ${message} + ` + } +}