Skip to content
Open
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
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,16 @@
* [x] Improve CLI output with colors (fatih/color) 🎨
* [x] Create a --output flag to save analyses in .md files
* [] Create a repo-review command to analyze an entire repository
* [] Allow filtering by file type? (--extensions=.go,.js)
* [] Implement parallel processing for faster analysis?
* [] Integrate repository history analysis? (git blame)
* [x] Use all files to train the AI, for code review and also for giving a general repository summary and description
* [] Optimize large repositories? (Chunking source code before sending to AI)
* [] Implement --skip-tests flag? (Exclude test files from AI training)
* [] Parallelize file fetching? (Speed up repository scanning)
* [] Run tests with coverage reports? (go test -cover)
* [] Integrate GitHub Actions for CI/CD testing?
* [] Add benchmark tests for performance? (go test -bench)
* [] Create a commit-review command to analyze individual commits
* [] Create a branch-review command to review an entire branch
* [] Add support for multiple programming languages
Expand All @@ -12,7 +22,6 @@
* [] Implement support for third-party plugins
* [] Create an improved help command with examples
* [] Create a --verbose mode to display detailed logs
* [] Create a web version of the tool with Next.js
* [] Add support to run the CLI inside Docker
* [] Create an offline mode that works without OpenAI
* [] Improve error handling and exception support
Expand Down
19 changes: 9 additions & 10 deletions cmd/pr_review_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@ package cmd

import (
"github.com/google/go-github/v49/github"
"github.com/gsoares85/code-guardian/internal/github_internal"
"github.com/gsoares85/code-guardian/internal/openai"
"github.com/gsoares85/code-guardian/mocks"
"github.com/stretchr/testify/assert"
"os"
"path/filepath"
Expand All @@ -15,10 +14,10 @@ func TestGenerateMarkdownReport(t *testing.T) {
prTitle := "Fix memory leak"
prOwner := "testUser"
prNumber := 23
pr, err := github_internal.MockGetPullRequest(prOwner, prTitle, prNumber)
files, err := github_internal.MockGetPullRequestFiles(prOwner, prTitle, prNumber)
prDiff, err := github_internal.MockGetPullRequestDiff(prOwner, prTitle, prNumber)
aiFeedback, err := openai.MockAnalyzePRWithAI(prDiff)
pr, err := mocks.MockGetPullRequest(prOwner, prTitle, prNumber)
files, err := mocks.MockGetPullRequestFiles(prOwner, prTitle, prNumber)
prDiff, err := mocks.MockGetPullRequestDiff(prOwner, prTitle, prNumber)
aiFeedback, err := mocks.MockAnalyzePRWithAI(prDiff)

report := generateMarkdownReport(pr, files, prDiff, aiFeedback)
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Test cases in cmd/pr_review_test.go file are not properly handling the errors from the Mock functions. For example, in the TestGenerateMarkdownReport test case, it might be beneficial to assert err is nil after each Mock function call to ensure there are no errors.


Expand Down Expand Up @@ -49,16 +48,16 @@ func TestSaveAnalysisToFile(t *testing.T) {
func TestAnalyzePullRequest(t *testing.T) {
// Use local variables in the test function to "mock" external functionality.
mockGetPullRequest := func(owner, title string, number int) (*github.PullRequest, error) {
return github_internal.MockGetPullRequest(owner, title, number)
return mocks.MockGetPullRequest(owner, title, number)
}
mockGetPullRequestFiles := func(owner, title string, number int) ([]string, error) {
return github_internal.MockGetPullRequestFiles(owner, title, number)
return mocks.MockGetPullRequestFiles(owner, title, number)
}
mockGetPullRequestDiff := func(owner, title string, number int) (string, error) {
return github_internal.MockGetPullRequestDiff(owner, title, number)
return mocks.MockGetPullRequestDiff(owner, title, number)
}
mockAnalyzePRWithAI := func(prDiff string) (string, error) {
return openai.MockAnalyzePRWithAI(prDiff)
return mocks.MockAnalyzePRWithAI(prDiff)
}

// Use the mocks for testing by calling them explicitly.
Expand Down
154 changes: 154 additions & 0 deletions cmd/repo_review.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
package cmd

import (
"fmt"
"github.com/fatih/color"
"github.com/gsoares85/code-guardian/internal/github_internal"
"github.com/gsoares85/code-guardian/internal/openai"
"github.com/spf13/cobra"
"path/filepath"
"strings"
"time"
)

var repoReviewCmd = &cobra.Command{
Use: "repo-review [owner] [repo] [flags]",
Short: "Analyse an entire github repository",
Long: `This command scans all source files recursively in a repository
to train the AI about the application and provide:
- A summary of what the application does
- Key use cases
- A high-level code quality review (only critical issues)
- A high-level security review (only critical issues)
- Key improvement areas`,
Args: cobra.ExactArgs(2),
Run: func(cmd *cobra.Command, args []string) {
owner, repo := args[0], args[1]
saveOutput, err := cmd.Flags().GetBool("output")
if err != nil {
color.Red("Error: %v", err)
return
}

color.Blue("\n🔍 Fetching all source code files recursively...\n")
files, err := github_internal.GetRepositoryFilesRecursive(owner, repo)
if err != nil {
color.Red("❌ ERROR: Fetching repository files: %s\n", err)
return
}

color.Green("📂 Repository contains %d files\n", len(files))

sourceCode := fetchAllSourceCode(owner, repo, files)
if len(sourceCode) == 0 {
color.Red("❌ ERROR: No valid source code found for analysis")
return
}

color.Blue("\n🤖 Training AI with full source code...\n")
summary, useCases, codeReview, securityReview, improvements := analyzeRepositoryWithAI(sourceCode)

displayRepoAnalysis(repo, summary, useCases, codeReview, securityReview, improvements)

if saveOutput {
saveRepoAnalysis(repo, owner, summary, useCases, codeReview, securityReview, improvements)
}
},
}

func fetchAllSourceCode(owner, repo string, files []string) string {
var allCode strings.Builder

for _, file := range files {
if strings.HasSuffix(file, ".md") || strings.Contains(file, "LICENSE") {
continue
}

color.Cyan("\n📄 Reading file: %s", file)

content, err := github_internal.GetFileContent(owner, repo, file)
if err != nil {
color.Red("❌ ERROR fetching file content: %s\n", err)
continue
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. In the file cmd/repo_review.go, within the fetchAllSourceCode function, we are ignoring any errors that occurred during the github_internal.GetFileContent function call. It would be better to also handle these errors properly instead of simply continuing the execution. It would be better to include some form of exception handling, for example logging it for future usage.

}

allCode.WriteString(fmt.Sprintf("\n// File: %s\n%s\n", file, content))
}

return allCode.String()
}

func analyzeRepositoryWithAI(sourceCode string) (string, string, string, string, string) {
summaryPrompt := "Analyze this entire source codebase and provide a concise summary of what the application does."
useCasesPrompt := "Extract the most important use cases from the source code."
codeQualityPrompt := "Identify the most critical code quality issues found in the source code. Provide a brief list."
securityPrompt := "Identify the most critical security vulnerabilities in the source code. Provide a brief list."
improvementPrompt := "Suggest the most important areas to improve in the application."

summary, _ := openai.AnalyzeCodeWithAI(sourceCode, summaryPrompt)
useCases, _ := openai.AnalyzeCodeWithAI(sourceCode, useCasesPrompt)
codeReview, _ := openai.AnalyzeCodeWithAI(sourceCode, codeQualityPrompt)
securityReview, _ := openai.AnalyzeCodeWithAI(sourceCode, securityPrompt)
improvements, _ := openai.AnalyzeCodeWithAI(sourceCode, improvementPrompt)

return summary, useCases, codeReview, securityReview, improvements
}

func displayRepoAnalysis(repo, summary, useCases, codeReview, securityReview, improvements string) {
color.Magenta("\n📌 Repository Analysis Summary for %s\n", repo)
color.Cyan("\n📖 Application Summary:\n")
fmt.Println(summary)
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. In the same file, within the displayRepoAnalysis function, we directly print out the analysis result without checking if they are empty. It might be good to verify these variables prior to printing.


color.Green("\n✅ Key Use Cases:\n")
fmt.Println(useCases)
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same


color.Red("\n🚨 Code Quality Issues (Critical Only):\n")
fmt.Println(codeReview)
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same


color.Yellow("\n🔒 Security Issues (Critical Only):\n")
fmt.Println(securityReview)
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same


color.Blue("\n📈 Key Areas for Improvement:\n")
fmt.Println(improvements)
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same

}

func saveRepoAnalysis(repo, owner, summary, useCases, codeReview, securityReview, improvements string) {
timestamp := time.Now().Format("20060102-150405")
outputFile := fmt.Sprintf("%s-%s_%s.md", timestamp, repo, owner)
outputPath := filepath.Join("reports", "repo", outputFile)

content := generateRepoMarkdown(repo, summary, useCases, codeReview, securityReview, improvements)

if err := saveAnalysisToFile(outputPath, content); err != nil {
color.Red("❌ ERROR saving analysis: %s\n", err)
return
}
color.Green("✅ Repository analysis saved to file: %s\n", outputPath)
}

func generateRepoMarkdown(repo, summary, useCases, codeReview, securityReview, improvements string) string {
return fmt.Sprintf(`# Repository Analysis Report

## 📂 Repository: %s

## 📖 Application Summary:
%s

## ✅ Key Use Cases:
%s

## 🚨 Code Quality Issues (Critical Only):
%s

## 🔒 Security Issues (Critical Only):
%s

## 📈 Key Areas for Improvement:
%s
`, repo, summary, useCases, codeReview, securityReview, improvements)
}

func init() {
rootCmd.AddCommand(repoReviewCmd)
repoReviewCmd.Flags().BoolP("output", "o", false, "Save analysis to a Markdown file in ./reports/repo/")
}
49 changes: 49 additions & 0 deletions internal/github_internal/github.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,52 @@ func GetPullRequestFiles(owner string, repo string, prNumber int) ([]string, err
}
return fileNames, nil
}

func GetRepositoryFilesRecursive(owner, repo string) ([]string, error) {
client := NewGithubClient()
var files []string

err := fetchFilesRecursive(client, owner, repo, "", &files)
if err != nil {
return nil, fmt.Errorf("error fetching repository files: %w", err)
}

return files, nil
}

func fetchFilesRecursive(client *github.Client, owner, repo, path string, files *[]string) error {
contents, dirContents, _, err := client.Repositories.GetContents(context.Background(), owner, repo, path, nil)
if err != nil {
return err
}

if contents != nil {
*files = append(*files, contents.GetPath())
return nil
}

for _, item := range dirContents {
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Inside the internal/github_internal/github.go file, within the fetchFilesRecursive function, we are not handling the error that might occur during recursive function call. It could potentially cause error hiding or handling issues. An error returned by fetchFilesRecursive function should be checked.

if item.GetType() == "file" {
*files = append(*files, item.GetPath())
} else if item.GetType() == "dir" {
fetchFilesRecursive(client, owner, repo, item.GetPath(), files)
}
}

return nil
}

func GetFileContent(owner, repo, path string) (string, error) {
client := NewGithubClient()
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. In the same file, within GetRepositoryFilesRecursive and GetFileContent functions, There seems to be a repetition of the NewGithubClient function call for each request, we might consider initiating the client at once and reusing it.

fileContent, _, _, err := client.Repositories.GetContents(context.Background(), owner, repo, path, nil)
if err != nil {
return "", fmt.Errorf("error fetching file content: %w", err)
}

content, err := fileContent.GetContent()
if err != nil {
return "", fmt.Errorf("error decoding file content: %w", err)
}

return content, nil
}
53 changes: 53 additions & 0 deletions internal/openai/openai.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"github.com/gsoares85/code-guardian/config"
"github.com/sashabaranov/go-openai"
"strings"
)

func AnalyzePRWithAI(diff string) (string, error) {
Expand Down Expand Up @@ -43,3 +44,55 @@ func AnalyzePRWithAI(diff string) (string, error) {

return resp.Choices[0].Message.Content, nil
}

func AnalyzeCodeWithAI(code string, prompt string) (string, error) {
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. The new internal/openai/openai.go seems to be repetitive as the code used in AnalyzePRWithAI and AnalyzeCodeWithAI methods is almost identical, and there might be an opportunity to refactor these two for simplicity and clarity, avoiding repetition.

apiKey := config.GetEnv("OPENAI_API_KEY")
if apiKey == "" {
return "", fmt.Errorf("missing OpenAI API key")
}

client := openai.NewClient(apiKey)

codeChunks := SplitLargeCode(code, 3000)

var fullResponse strings.Builder

for _, chunk := range codeChunks {
requestPrompt := fmt.Sprintf("%s\n\n%s", prompt, chunk)

resp, err := client.CreateChatCompletion(context.Background(), openai.ChatCompletionRequest{
Model: openai.GPT4,
Messages: []openai.ChatCompletionMessage{
{Role: "system", Content: "You are a senior software engineer reviewing code."},
{Role: "user", Content: requestPrompt},
},
})

if err != nil {
return "", err
}

if len(resp.Choices) == 0 {
return "", fmt.Errorf("empty response from OpenAI")
}

fullResponse.WriteString(resp.Choices[0].Message.Content + "\n\n")
}

return fullResponse.String(), nil
}

func SplitLargeCode(code string, maxTokens int) []string {
words := strings.Fields(code)
var chunks []string

for i := 0; i < len(words); i += maxTokens {
end := i + maxTokens
if end > len(words) {
end = len(words)
}
chunks = append(chunks, strings.Join(words[i:end], " "))
}

return chunks
}
28 changes: 24 additions & 4 deletions internal/github_internal/github_mock.go → mocks/github_mock.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
package github_internal
package mocks

import "github.com/google/go-github/v49/github"

// Mock function to simulate fetching a PR
func MockGetPullRequest(owner string, repo string, prNumber int) (*github.PullRequest, error) {
return &github.PullRequest{
Number: github.Int(prNumber),
Expand All @@ -12,12 +11,10 @@ func MockGetPullRequest(owner string, repo string, prNumber int) (*github.PullRe
}, nil
}

// Mock function to simulate fetching changed files
func MockGetPullRequestFiles(_ string, _ string, _ int) ([]string, error) {
return []string{"src/main.c", "include/utils.h"}, nil
}

// Mock function to simulate fetching PR diff
func MockGetPullRequestDiff(_ string, _ string, _ int) (string, error) {
return `@@ -23,6 +23,7 @@
void fixMemory() {
Expand All @@ -26,3 +23,26 @@ func MockGetPullRequestDiff(_ string, _ string, _ int) (string, error) {
+ free(ptr);
}`, nil
}

func MockGetRepositoryFilesRecursive(owner, repo string) ([]string, error) {
return []string{
"src/main.go",
"src/utils/helper.go",
"config/config.yaml",
"README.md",
}, nil
}

func MockGetFileContent(owner, repo, path string) (string, error) {
if path == "src/main.go" {
return `package main

import "fmt"

func main() {
fmt.Println("Hello, world!")
}`, nil
}

return "", nil
}
Loading