diff --git a/.github/workflows/review.yml b/.github/workflows/review.yml new file mode 100644 index 0000000..85f6046 --- /dev/null +++ b/.github/workflows/review.yml @@ -0,0 +1,17 @@ +name: Code Review + +on: + pull_request: + types: [opened, synchronize] + pull_request_review_comment: + types: [created] + +jobs: + review: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - uses: ./ + with: + gemini-api-key: ${{ secrets.GEMINI_API_KEY }} diff --git a/.gitignore b/.gitignore index b99a396..dd75555 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,7 @@ *.dll *.so *.dylib -codereview +/codereview # Test binary *.test diff --git a/.projections.json b/.projections.json new file mode 100644 index 0000000..fa58f02 --- /dev/null +++ b/.projections.json @@ -0,0 +1,21 @@ +{ + "internal/*.go": { + "alternate": "internal/{}_test.go", + "type": "source" + }, + "internal/*_test.go": { + "alternate": "internal/{}.go", + "type": "test" + }, + "internal/**/*.go": { + "alternate": "internal/**/{}_test.go", + "type": "source" + }, + "internal/**/*_test.go": { + "alternate": "internal/**/{}.go", + "type": "test" + }, + "cmd/codereview/*.go": { + "type": "command" + } +} diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..ad12b10 --- /dev/null +++ b/action.yml @@ -0,0 +1,48 @@ +name: 'Code Review' +description: 'LLM-powered PR code review with conventional comments' + +inputs: + github-token: + description: 'GitHub token for API access' + required: true + default: '${{ github.token }}' + provider: + description: 'LLM provider' + required: false + default: 'gemini' + gemini-api-key: + description: 'API key for Google Gemini' + required: false + model: + description: 'Model name override' + required: false + instructions: + description: 'Additional review instructions' + required: false + bot-login: + description: 'Bot username to ignore for loop prevention with custom tokens' + required: false + +runs: + using: 'composite' + steps: + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '1.24' + cache-dependency-path: go.sum + + - name: Build + shell: bash + run: cd ${{ github.action_path }} && go build -o $RUNNER_TEMP/codereview ./cmd/codereview + + - name: Review + shell: bash + env: + INPUT_GITHUB_TOKEN: ${{ inputs.github-token }} + INPUT_PROVIDER: ${{ inputs.provider }} + INPUT_GEMINI_API_KEY: ${{ inputs.gemini-api-key }} + INPUT_MODEL: ${{ inputs.model }} + INPUT_INSTRUCTIONS: ${{ inputs.instructions }} + INPUT_BOT_LOGIN: ${{ inputs.bot-login }} + run: $RUNNER_TEMP/codereview diff --git a/cmd/codereview/main.go b/cmd/codereview/main.go new file mode 100644 index 0000000..53e3f8a --- /dev/null +++ b/cmd/codereview/main.go @@ -0,0 +1,185 @@ +package main + +import ( + "context" + "fmt" + "os" + + "github.com/monokrome/codereview/internal/action" + "github.com/monokrome/codereview/internal/diff" + "github.com/monokrome/codereview/internal/github" + "github.com/monokrome/codereview/internal/prompt" + "github.com/monokrome/codereview/internal/provider" + "github.com/monokrome/codereview/internal/provider/gemini" + "github.com/monokrome/codereview/internal/review" +) + +const defaultBotLogin = "github-actions[bot]" + +func main() { + if err := run(); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } +} + +func run() error { + ctx := context.Background() + + cfg, err := action.Parse() + if err != nil { + return fmt.Errorf("parsing inputs: %w", err) + } + + var providerFn provider.ReviewFunc + switch cfg.Provider { + case "gemini": + if cfg.GeminiAPIKey == "" { + return fmt.Errorf("INPUT_GEMINI_API_KEY is required for gemini provider") + } + providerFn = gemini.New(cfg.GeminiAPIKey, cfg.Model) + default: + return fmt.Errorf("unsupported provider: %s", cfg.Provider) + } + + gh := github.New(cfg.GitHubToken) + + switch cfg.Mode { + case action.ModeReview: + return runReview(ctx, cfg, providerFn, gh) + case action.ModeReply: + return runReply(ctx, cfg, providerFn, gh) + default: + return fmt.Errorf("unknown mode: %s", cfg.Mode) + } +} + +func runReview(ctx context.Context, cfg action.Config, providerFn provider.ReviewFunc, gh *github.Client) error { + diffText, err := gh.FetchDiff(ctx, cfg.Owner, cfg.Repo, cfg.PRNumber) + if err != nil { + return fmt.Errorf("fetching diff: %w", err) + } + + botLogin := cfg.BotLogin + if botLogin == "" { + botLogin = defaultBotLogin + } + + priorGH, err := gh.FetchBotReviewComments(ctx, cfg.Owner, cfg.Repo, cfg.PRNumber, botLogin) + if err != nil { + fmt.Fprintf(os.Stderr, "warning: could not fetch prior comments: %v\n", err) + } + + var priorComments []prompt.PriorComment + for _, pc := range priorGH { + priorComments = append(priorComments, prompt.PriorComment{ + Path: pc.Path, + Body: pc.Body, + }) + } + + fileContents := fetchChangedFiles(ctx, gh, cfg, diffText) + + result, err := review.Run(ctx, review.Config{ + Diff: diffText, + Provider: providerFn, + Instructions: cfg.Instructions, + PriorComments: priorComments, + FileContents: fileContents, + }) + if err != nil { + return fmt.Errorf("running review: %w", err) + } + + if err := gh.SubmitReview(ctx, cfg.Owner, cfg.Repo, cfg.PRNumber, cfg.CommitSHA, result); err != nil { + return fmt.Errorf("submitting review: %w", err) + } + + fmt.Fprintf(os.Stderr, "review submitted: %s\n", result.Verdict) + return nil +} + +func runReply(ctx context.Context, cfg action.Config, providerFn provider.ReviewFunc, gh *github.Client) error { + if cfg.SkipReply { + fmt.Fprintf(os.Stderr, "skipping: comment is from bot or is a top-level comment\n") + return nil + } + + thread, err := gh.FetchCommentThread(ctx, cfg.Owner, cfg.Repo, cfg.PRNumber, cfg.Comment.CommentID) + if err != nil { + return fmt.Errorf("fetching comment thread: %w", err) + } + + var messages []prompt.ThreadMessage + for _, tc := range thread { + messages = append(messages, prompt.ThreadMessage{ + Author: tc.UserLogin, + Body: tc.Body, + }) + } + + result, err := review.RunReply(ctx, review.ReplyConfig{ + Provider: providerFn, + Thread: messages, + DiffHunk: cfg.Comment.DiffHunk, + Instructions: cfg.Instructions, + }) + if err != nil { + return fmt.Errorf("generating reply: %w", err) + } + + replyTo := cfg.Comment.InReplyToID + if replyTo == 0 { + replyTo = cfg.Comment.CommentID + } + + if err := gh.ReplyToComment(ctx, cfg.Owner, cfg.Repo, cfg.PRNumber, replyTo, result.Reply); err != nil { + return fmt.Errorf("posting reply: %w", err) + } + + if result.Resolved { + botLogin := cfg.BotLogin + if botLogin == "" { + botLogin = defaultBotLogin + } + + rootID := cfg.Comment.InReplyToID + if rootID == 0 { + rootID = cfg.Comment.CommentID + } + + if err := gh.ResolveThread(ctx, cfg.Owner, cfg.Repo, cfg.PRNumber, rootID, botLogin); err != nil { + fmt.Fprintf(os.Stderr, "warning: could not resolve thread: %v\n", err) + } else { + fmt.Fprintf(os.Stderr, "thread resolved\n") + } + } + + fmt.Fprintf(os.Stderr, "reply posted\n") + return nil +} + +func fetchChangedFiles(ctx context.Context, gh *github.Client, cfg action.Config, diffText string) map[string]string { + files, err := diff.Parse(diffText) + if err != nil { + fmt.Fprintf(os.Stderr, "warning: could not parse diff for file fetching: %v\n", err) + return nil + } + + contents := make(map[string]string) + for _, f := range files { + content, err := gh.FetchFile(ctx, cfg.Owner, cfg.Repo, cfg.CommitSHA, f.Path) + if err != nil { + fmt.Fprintf(os.Stderr, "warning: could not fetch %s: %v\n", f.Path, err) + continue + } + + if content == "" { + continue + } + + contents[f.Path] = content + } + + return contents +} diff --git a/internal/action/inputs.go b/internal/action/inputs.go new file mode 100644 index 0000000..6fda527 --- /dev/null +++ b/internal/action/inputs.go @@ -0,0 +1,167 @@ +package action + +import ( + "encoding/json" + "fmt" + "os" + "strconv" +) + +const ( + ModeReview = "review" + ModeReply = "reply" + + defaultBotLogin = "github-actions[bot]" +) + +type CommentContext struct { + CommentID int64 + InReplyToID int64 + Body string + UserLogin string + Path string + Line int + DiffHunk string +} + +type Config struct { + GitHubToken string + Provider string + GeminiAPIKey string + Model string + Instructions string + BotLogin string + Owner string + Repo string + PRNumber int + CommitSHA string + Mode string + Comment *CommentContext + SkipReply bool +} + +type githubEvent struct { + PullRequest struct { + Number int `json:"number"` + Head struct { + SHA string `json:"sha"` + } `json:"head"` + } `json:"pull_request"` + Repository struct { + Owner struct { + Login string `json:"login"` + } `json:"owner"` + Name string `json:"name"` + } `json:"repository"` + Comment struct { + ID int64 `json:"id"` + InReplyToID int64 `json:"in_reply_to_id"` + Body string `json:"body"` + Path string `json:"path"` + Line int `json:"line"` + DiffHunk string `json:"diff_hunk"` + CommitID string `json:"commit_id"` + User struct { + Login string `json:"login"` + } `json:"user"` + } `json:"comment"` +} + +func Parse() (Config, error) { + cfg := Config{ + GitHubToken: os.Getenv("INPUT_GITHUB_TOKEN"), + Provider: os.Getenv("INPUT_PROVIDER"), + GeminiAPIKey: os.Getenv("INPUT_GEMINI_API_KEY"), + Model: os.Getenv("INPUT_MODEL"), + Instructions: os.Getenv("INPUT_INSTRUCTIONS"), + BotLogin: os.Getenv("INPUT_BOT_LOGIN"), + } + + if cfg.GitHubToken == "" { + return Config{}, fmt.Errorf("INPUT_GITHUB_TOKEN is required") + } + + if cfg.Provider == "" { + cfg.Provider = "gemini" + } + + eventPath := os.Getenv("GITHUB_EVENT_PATH") + if eventPath == "" { + return Config{}, fmt.Errorf("GITHUB_EVENT_PATH is required") + } + + data, err := os.ReadFile(eventPath) + if err != nil { + return Config{}, fmt.Errorf("reading event file: %w", err) + } + + var event githubEvent + if err := json.Unmarshal(data, &event); err != nil { + return Config{}, fmt.Errorf("parsing event JSON: %w", err) + } + + cfg.Owner = event.Repository.Owner.Login + cfg.Repo = event.Repository.Name + cfg.PRNumber = event.PullRequest.Number + cfg.CommitSHA = event.PullRequest.Head.SHA + + eventName := os.Getenv("GITHUB_EVENT_NAME") + switch eventName { + case "pull_request_review_comment": + cfg.Mode = ModeReply + cfg.Comment = &CommentContext{ + CommentID: event.Comment.ID, + InReplyToID: event.Comment.InReplyToID, + Body: event.Comment.Body, + UserLogin: event.Comment.User.Login, + Path: event.Comment.Path, + Line: event.Comment.Line, + DiffHunk: event.Comment.DiffHunk, + } + + if event.Comment.CommitID != "" { + cfg.CommitSHA = event.Comment.CommitID + } + + botCheck := cfg.BotLogin + if botCheck == "" { + botCheck = defaultBotLogin + } + + if cfg.Comment.UserLogin == botCheck { + cfg.SkipReply = true + } + + if cfg.Comment.InReplyToID == 0 { + cfg.SkipReply = true + } + default: + cfg.Mode = ModeReview + } + + if override := os.Getenv("INPUT_PR_NUMBER"); override != "" { + n, err := strconv.Atoi(override) + if err != nil { + return Config{}, fmt.Errorf("parsing INPUT_PR_NUMBER: %w", err) + } + cfg.PRNumber = n + } + + if cfg.Owner == "" { + return Config{}, fmt.Errorf("repository owner not found in event") + } + + if cfg.Repo == "" { + return Config{}, fmt.Errorf("repository name not found in event") + } + + if cfg.PRNumber == 0 { + return Config{}, fmt.Errorf("pull request number not found in event") + } + + if cfg.CommitSHA == "" { + return Config{}, fmt.Errorf("commit SHA not found in event") + } + + return cfg, nil +} diff --git a/internal/diff/parse.go b/internal/diff/parse.go new file mode 100644 index 0000000..a2f1f41 --- /dev/null +++ b/internal/diff/parse.go @@ -0,0 +1,142 @@ +package diff + +import ( + "fmt" + "strconv" + "strings" +) + +const ( + diffGitPrefix = "diff --git " + hunkPrefix = "@@" + addedPrefix = "+" + removedPrefix = "-" + devNull = "/dev/null" +) + +func Parse(diffText string) ([]File, error) { + lines := strings.Split(diffText, "\n") + var files []File + var current *File + + for i := 0; i < len(lines); i++ { + line := lines[i] + + if strings.HasPrefix(line, diffGitPrefix) { + path := extractPath(line) + newFile := File{Path: path} + files = append(files, newFile) + current = &files[len(files)-1] + current.Path = resolvePathFromHeaders(lines, i, path) + continue + } + + if current == nil { + continue + } + + if !strings.HasPrefix(line, hunkPrefix) { + continue + } + + hunk, err := parseHunkHeader(line) + if err != nil { + return nil, fmt.Errorf("parsing hunk header %q: %w", line, err) + } + + lineNum := hunk.StartLine + i++ + + for i < len(lines) && !strings.HasPrefix(lines[i], diffGitPrefix) && !strings.HasPrefix(lines[i], hunkPrefix) { + raw := lines[i] + + if raw == `\ No newline at end of file` { + i++ + continue + } + + if len(raw) == 0 { + hunk.Lines = append(hunk.Lines, Line{Number: lineNum, Kind: KindContext, Content: ""}) + lineNum++ + i++ + continue + } + + switch raw[0] { + case '+': + hunk.Lines = append(hunk.Lines, Line{Number: lineNum, Kind: KindAdded, Content: raw[1:]}) + lineNum++ + case '-': + hunk.Lines = append(hunk.Lines, Line{Number: lineNum, Kind: KindRemoved, Content: raw[1:]}) + default: + hunk.Lines = append(hunk.Lines, Line{Number: lineNum, Kind: KindContext, Content: trimLeadingSpace(raw)}) + lineNum++ + } + + i++ + } + + current.Hunks = append(current.Hunks, hunk) + i-- + } + + return files, nil +} + +func extractPath(gitDiffLine string) string { + trimmed := strings.TrimPrefix(gitDiffLine, diffGitPrefix) + parts := strings.SplitN(trimmed, " ", 2) + if len(parts) < 2 { + return strings.TrimPrefix(trimmed, "a/") + } + return strings.TrimPrefix(parts[1], "b/") +} + +func resolvePathFromHeaders(lines []string, start int, fallback string) string { + for j := start + 1; j < len(lines) && j <= start+4; j++ { + if strings.HasPrefix(lines[j], diffGitPrefix) || strings.HasPrefix(lines[j], hunkPrefix) { + break + } + + if strings.HasPrefix(lines[j], "+++ ") { + target := strings.TrimPrefix(lines[j], "+++ ") + if target == devNull { + return fallback + } + return strings.TrimPrefix(target, "b/") + } + } + return fallback +} + +func parseHunkHeader(line string) (Hunk, error) { + atIdx := strings.Index(line, "+") + if atIdx < 0 { + return Hunk{}, fmt.Errorf("no + range in hunk header") + } + + rest := line[atIdx+1:] + endIdx := strings.Index(rest, " ") + if endIdx < 0 { + endIdx = strings.Index(rest, hunkPrefix) + } + if endIdx < 0 { + endIdx = len(rest) + } + + rangeStr := rest[:endIdx] + parts := strings.SplitN(rangeStr, ",", 2) + startLine, err := strconv.Atoi(parts[0]) + if err != nil { + return Hunk{}, fmt.Errorf("parsing start line %q: %w", parts[0], err) + } + + return Hunk{StartLine: startLine}, nil +} + +func trimLeadingSpace(s string) string { + if len(s) > 0 && s[0] == ' ' { + return s[1:] + } + return s +} diff --git a/internal/diff/types.go b/internal/diff/types.go new file mode 100644 index 0000000..223bb24 --- /dev/null +++ b/internal/diff/types.go @@ -0,0 +1,25 @@ +package diff + +type LineKind int + +const ( + KindContext LineKind = iota + KindAdded + KindRemoved +) + +type Line struct { + Number int + Kind LineKind + Content string +} + +type Hunk struct { + StartLine int + Lines []Line +} + +type File struct { + Path string + Hunks []Hunk +} diff --git a/internal/github/client.go b/internal/github/client.go new file mode 100644 index 0000000..9cd8a11 --- /dev/null +++ b/internal/github/client.go @@ -0,0 +1,492 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/monokrome/codereview/internal/review" +) + +const apiBase = "https://api.github.com" + +type Client struct { + token string + httpClient *http.Client +} + +func New(token string) *Client { + return &Client{ + token: token, + httpClient: &http.Client{}, + } +} + +type apiComment struct { + ID int64 `json:"id"` + InReplyToID int64 `json:"in_reply_to_id"` + Body string `json:"body"` + Path string `json:"path"` + Line int `json:"line"` + DiffHunk string `json:"diff_hunk"` + CreatedAt string `json:"created_at"` + User struct { + Login string `json:"login"` + } `json:"user"` +} + +func (c *Client) fetchAllPRComments(ctx context.Context, owner, repo string, prNumber int) ([]apiComment, error) { + var all []apiComment + + page := 1 + for { + url := fmt.Sprintf("%s/repos/%s/%s/pulls/%d/comments?per_page=100&page=%d", apiBase, owner, repo, prNumber, page) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("Accept", "application/vnd.github.v3+json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("fetching comments: %w", err) + } + + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body)) + } + + var pageComments []apiComment + if err := json.Unmarshal(body, &pageComments); err != nil { + return nil, fmt.Errorf("parsing comments: %w", err) + } + + all = append(all, pageComments...) + + if len(pageComments) < 100 { + break + } + page++ + } + + return all, nil +} + +func (c *Client) FetchDiff(ctx context.Context, owner, repo string, prNumber int) (string, error) { + url := fmt.Sprintf("%s/repos/%s/%s/pulls/%d", apiBase, owner, repo, prNumber) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", fmt.Errorf("creating request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("Accept", "application/vnd.github.v3.diff") + + resp, err := c.httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("fetching diff: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body)) + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("reading response: %w", err) + } + + return string(data), nil +} + +const maxFileSize = 256 * 1024 // 256KB + +func (c *Client) FetchFile(ctx context.Context, owner, repo, ref, path string) (string, error) { + url := fmt.Sprintf("%s/repos/%s/%s/contents/%s?ref=%s", apiBase, owner, repo, path, ref) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", fmt.Errorf("creating request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("Accept", "application/vnd.github.v3.raw") + + resp, err := c.httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("fetching file: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return "", nil + } + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body)) + } + + data, err := io.ReadAll(io.LimitReader(resp.Body, maxFileSize+1)) + if err != nil { + return "", fmt.Errorf("reading file: %w", err) + } + + if len(data) > maxFileSize { + return "", nil + } + + return string(data), nil +} + +type submitComment struct { + Path string `json:"path"` + Line int `json:"line"` + Body string `json:"body"` + Side string `json:"side"` +} + +type submitRequest struct { + Event string `json:"event"` + Body string `json:"body"` + Comments []submitComment `json:"comments"` + CommitID string `json:"commit_id"` +} + +func (c *Client) SubmitReview(ctx context.Context, owner, repo string, prNumber int, commitSHA string, result review.Result) error { + comments := make([]submitComment, len(result.Comments)) + for i, rc := range result.Comments { + comments[i] = submitComment{ + Path: rc.Path, + Line: rc.Line, + Body: rc.Body, + Side: "RIGHT", + } + } + + payload := submitRequest{ + Event: string(result.Verdict), + Body: result.Summary, + Comments: comments, + CommitID: commitSHA, + } + + body, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshalling review: %w", err) + } + + url := fmt.Sprintf("%s/repos/%s/%s/pulls/%d/reviews", apiBase, owner, repo, prNumber) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(string(body))) + if err != nil { + return fmt.Errorf("creating request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("Accept", "application/vnd.github.v3+json") + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("submitting review: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(respBody)) + } + + return nil +} + +type ThreadComment struct { + ID int64 + InReplyToID int64 + Body string + UserLogin string + Path string + Line int + DiffHunk string + CreatedAt string +} + +func toThreadComment(c apiComment) ThreadComment { + return ThreadComment{ + ID: c.ID, + InReplyToID: c.InReplyToID, + Body: c.Body, + UserLogin: c.User.Login, + Path: c.Path, + Line: c.Line, + DiffHunk: c.DiffHunk, + CreatedAt: c.CreatedAt, + } +} + +func (c *Client) FetchCommentThread(ctx context.Context, owner, repo string, prNumber int, commentID int64) ([]ThreadComment, error) { + allComments, err := c.fetchAllPRComments(ctx, owner, repo, prNumber) + if err != nil { + return nil, err + } + + commentsByID := make(map[int64]apiComment) + for _, ac := range allComments { + commentsByID[ac.ID] = ac + } + + rootID := commentID + if target, ok := commentsByID[commentID]; ok && target.InReplyToID != 0 { + rootID = target.InReplyToID + } + + var thread []ThreadComment + for _, ac := range allComments { + if ac.ID == rootID || ac.InReplyToID == rootID { + thread = append(thread, toThreadComment(ac)) + } + } + + return thread, nil +} + +type PriorComment struct { + Path string + Line int + Body string +} + +func (c *Client) FetchBotReviewComments(ctx context.Context, owner, repo string, prNumber int, botLogin string) ([]PriorComment, error) { + allComments, err := c.fetchAllPRComments(ctx, owner, repo, prNumber) + if err != nil { + return nil, err + } + + var prior []PriorComment + for _, ac := range allComments { + if ac.User.Login == botLogin { + prior = append(prior, PriorComment{ + Path: ac.Path, + Line: ac.Line, + Body: ac.Body, + }) + } + } + + return prior, nil +} + +type replyRequest struct { + Body string `json:"body"` + InReplyTo int64 `json:"in_reply_to"` +} + +func (c *Client) ReplyToComment(ctx context.Context, owner, repo string, prNumber int, inReplyTo int64, body string) error { + payload := replyRequest{ + Body: body, + InReplyTo: inReplyTo, + } + + reqBody, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshalling reply: %w", err) + } + + url := fmt.Sprintf("%s/repos/%s/%s/pulls/%d/comments", apiBase, owner, repo, prNumber) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(string(reqBody))) + if err != nil { + return fmt.Errorf("creating request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("Accept", "application/vnd.github.v3+json") + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("posting reply: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(respBody)) + } + + return nil +} + +const graphqlEndpoint = "https://api.github.com/graphql" + +type graphqlRequest struct { + Query string `json:"query"` + Variables map[string]any `json:"variables"` +} + +type graphqlResponse struct { + Data json.RawMessage `json:"data"` + Errors []struct { + Message string `json:"message"` + } `json:"errors"` +} + +func (c *Client) ResolveThread(ctx context.Context, owner, repo string, prNumber int, rootCommentID int64, botLogin string) error { + threadID, isBotThread, err := c.findThreadByComment(ctx, owner, repo, prNumber, rootCommentID, botLogin) + if err != nil { + return fmt.Errorf("finding thread: %w", err) + } + + if !isBotThread { + return nil + } + + if threadID == "" { + return fmt.Errorf("thread not found for comment %d", rootCommentID) + } + + mutation := `mutation($threadId: ID!) { + resolveReviewThread(input: { threadId: $threadId }) { + thread { id isResolved } + } + }` + + return c.graphql(ctx, mutation, map[string]any{"threadId": threadID}) +} + +func (c *Client) findThreadByComment(ctx context.Context, owner, repo string, prNumber int, commentID int64, botLogin string) (string, bool, error) { + query := `query($owner: String!, $repo: String!, $prNumber: Int!, $cursor: String) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $prNumber) { + reviewThreads(first: 100, after: $cursor) { + pageInfo { hasNextPage endCursor } + nodes { + id + comments(first: 1) { + nodes { databaseId author { login } } + } + } + } + } + } + }` + + vars := map[string]any{ + "owner": owner, + "repo": repo, + "prNumber": prNumber, + "cursor": nil, + } + + for { + body, err := c.graphqlRaw(ctx, query, vars) + if err != nil { + return "", false, err + } + + var result struct { + Repository struct { + PullRequest struct { + ReviewThreads struct { + PageInfo struct { + HasNextPage bool `json:"hasNextPage"` + EndCursor string `json:"endCursor"` + } `json:"pageInfo"` + Nodes []struct { + ID string `json:"id"` + Comments struct { + Nodes []struct { + DatabaseID int64 `json:"databaseId"` + Author struct { + Login string `json:"login"` + } `json:"author"` + } `json:"nodes"` + } `json:"comments"` + } `json:"nodes"` + } `json:"reviewThreads"` + } `json:"pullRequest"` + } `json:"repository"` + } + + if err := json.Unmarshal(body, &result); err != nil { + return "", false, fmt.Errorf("parsing response: %w", err) + } + + threads := result.Repository.PullRequest.ReviewThreads + for _, thread := range threads.Nodes { + if len(thread.Comments.Nodes) == 0 { + continue + } + + firstComment := thread.Comments.Nodes[0] + if firstComment.DatabaseID == commentID { + isBotThread := firstComment.Author.Login == botLogin + return thread.ID, isBotThread, nil + } + } + + if !threads.PageInfo.HasNextPage { + break + } + vars["cursor"] = threads.PageInfo.EndCursor + } + + return "", false, nil +} + +func (c *Client) graphql(ctx context.Context, query string, vars map[string]any) error { + _, err := c.graphqlRaw(ctx, query, vars) + return err +} + +func (c *Client) graphqlRaw(ctx context.Context, query string, vars map[string]any) (json.RawMessage, error) { + payload := graphqlRequest{Query: query, Variables: vars} + + body, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("marshalling request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, graphqlEndpoint, strings.NewReader(string(body))) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("executing request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading response: %w", err) + } + + var gqlResp graphqlResponse + if err := json.Unmarshal(respBody, &gqlResp); err != nil { + return nil, fmt.Errorf("parsing response: %w", err) + } + + if len(gqlResp.Errors) > 0 { + return nil, fmt.Errorf("graphql error: %s", gqlResp.Errors[0].Message) + } + + return gqlResp.Data, nil +} diff --git a/internal/prompt/prompt.go b/internal/prompt/prompt.go new file mode 100644 index 0000000..16e6589 --- /dev/null +++ b/internal/prompt/prompt.go @@ -0,0 +1,140 @@ +package prompt + +import ( + "fmt" + "strings" + + "github.com/monokrome/codereview/internal/diff" +) + +const systemTemplate = `You are a senior code reviewer. Review the provided unified diff and produce a JSON response. + +Use these conventional comment labels: +- "nit": style or trivial improvements that don't affect correctness +- "suggestion": a better approach or alternative worth considering +- "issue": a bug, logic error, or correctness problem that must be fixed +- "question": something unclear that needs the author's explanation +- "thought": an observation or design consideration, not actionable +- "chore": maintenance tasks like updating dependencies, fixing typos, or cleanup +- "praise": something done well that deserves recognition + +Verdict rules: +- Use "APPROVE" when there are no issues or only nits/praise/thoughts +- Use "REQUEST_CHANGES" when there are any comments with the "issue" label +- Use "COMMENT" for everything else (suggestions, questions, chores without issues) + +Your response must be valid JSON matching this exact structure: +{ + "verdict": "APPROVE" | "REQUEST_CHANGES" | "COMMENT", + "summary": "brief overall summary of the review", + "comments": [ + { + "path": "file/path.go", + "line": 42, + "label": "issue", + "body": "issue: description of the problem" + } + ] +} + +Rules: +- The "line" field must reference a valid line number from the diff (a line that was added or exists as context in the new file) +- The "body" field must start with the label followed by a colon and space (e.g. "nit: unused import") +- The "path" field must match a file path from the diff exactly +- Only comment on meaningful changes; do not comment on every line +- Be concise and actionable +- Output ONLY the JSON object, no markdown fences, no extra text` + +const replySystemTemplate = `You are a senior code reviewer engaged in a conversation about a code review comment. You are replying to a developer who responded to one of your review comments. + +Rules: +- Be concise and directly address the question or comment +- Reference the code when relevant +- If the developer's response resolves your concern, acknowledge it +- If you still have concerns, explain why clearly +- Be collaborative, not adversarial + +Your response must be valid JSON matching this exact structure: +{ + "reply": "your reply text here", + "resolved": true +} + +- Set "resolved" to true if the developer's response adequately addresses your original concern +- Set "resolved" to false if the concern is not yet addressed or you have follow-up questions +- Output ONLY the JSON object, no markdown fences, no extra text` + +type PriorComment struct { + Path string + Body string +} + +func Build(files []diff.File, instructions string, priorComments []PriorComment, fileContents map[string]string) (string, string) { + var user strings.Builder + + if instructions != "" { + fmt.Fprintf(&user, "Additional review instructions:\n%s\n\n", instructions) + } + + if len(priorComments) > 0 { + user.WriteString("You have already made the following review comments on this PR. Do NOT repeat these. Only raise new issues or comment on changes that address (or fail to address) your prior feedback:\n\n") + for _, pc := range priorComments { + fmt.Fprintf(&user, "- [%s] %s\n", pc.Path, pc.Body) + } + user.WriteString("\n") + } + + if len(fileContents) > 0 { + user.WriteString("Full file contents for context (use these to understand the surrounding code):\n\n") + for path, content := range fileContents { + fmt.Fprintf(&user, "=== %s ===\n%s\n\n", path, content) + } + } + + user.WriteString("Review the following diff:\n\n") + + for _, f := range files { + fmt.Fprintf(&user, "--- a/%s\n+++ b/%s\n", f.Path, f.Path) + + for _, h := range f.Hunks { + fmt.Fprintf(&user, "@@ -%d +%d @@\n", h.StartLine, h.StartLine) + + for _, l := range h.Lines { + switch l.Kind { + case diff.KindAdded: + fmt.Fprintf(&user, "+%s\n", l.Content) + case diff.KindRemoved: + fmt.Fprintf(&user, "-%s\n", l.Content) + default: + fmt.Fprintf(&user, " %s\n", l.Content) + } + } + } + } + + return systemTemplate, user.String() +} + +type ThreadMessage struct { + Author string + Body string +} + +func BuildReply(thread []ThreadMessage, diffHunk string, instructions string) (string, string) { + var user strings.Builder + + if instructions != "" { + fmt.Fprintf(&user, "Additional context:\n%s\n\n", instructions) + } + + fmt.Fprintf(&user, "Relevant code:\n```\n%s\n```\n\n", diffHunk) + + user.WriteString("Conversation thread:\n\n") + for _, msg := range thread { + fmt.Fprintf(&user, "**%s:**\n%s\n\n", msg.Author, msg.Body) + } + + user.WriteString("Write your reply to the latest message.") + + return replySystemTemplate, user.String() +} diff --git a/internal/provider/gemini/gemini.go b/internal/provider/gemini/gemini.go new file mode 100644 index 0000000..f3d86c9 --- /dev/null +++ b/internal/provider/gemini/gemini.go @@ -0,0 +1,104 @@ +package gemini + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/monokrome/codereview/internal/provider" +) + +const ( + DefaultModel = "gemini-2.5-flash" + apiBaseURL = "https://generativelanguage.googleapis.com/v1beta/models" +) + +type part struct { + Text string `json:"text"` +} + +type content struct { + Role string `json:"role,omitempty"` + Parts []part `json:"parts"` +} + +type generateRequest struct { + Contents []content `json:"contents"` + SystemInstruction *content `json:"systemInstruction,omitempty"` +} + +type candidate struct { + Content content `json:"content"` +} + +type generateResponse struct { + Candidates []candidate `json:"candidates"` +} + +func New(apiKey string, model string) provider.ReviewFunc { + if model == "" { + model = DefaultModel + } + + return func(ctx context.Context, req provider.Request) (provider.Response, error) { + payload := generateRequest{ + Contents: []content{ + {Role: "user", Parts: []part{{Text: req.UserPrompt}}}, + }, + } + + if req.SystemPrompt != "" { + payload.SystemInstruction = &content{ + Parts: []part{{Text: req.SystemPrompt}}, + } + } + + body, err := json.Marshal(payload) + if err != nil { + return provider.Response{}, fmt.Errorf("marshalling request: %w", err) + } + + url := fmt.Sprintf("%s/%s:generateContent?key=%s", apiBaseURL, model, apiKey) + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(string(body))) + if err != nil { + return provider.Response{}, fmt.Errorf("creating request: %w", err) + } + + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(httpReq) + if err != nil { + return provider.Response{}, fmt.Errorf("calling Gemini API: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return provider.Response{}, fmt.Errorf("reading response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return provider.Response{}, fmt.Errorf("Gemini API returned status %d: %s", resp.StatusCode, string(respBody)) + } + + var genResp generateResponse + if err := json.Unmarshal(respBody, &genResp); err != nil { + return provider.Response{}, fmt.Errorf("parsing response: %w", err) + } + + if len(genResp.Candidates) == 0 { + return provider.Response{}, fmt.Errorf("no candidates in response") + } + + parts := genResp.Candidates[0].Content.Parts + if len(parts) == 0 { + return provider.Response{}, fmt.Errorf("no parts in response candidate") + } + + return provider.Response{Content: parts[0].Text}, nil + } +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go new file mode 100644 index 0000000..fcfe53b --- /dev/null +++ b/internal/provider/provider.go @@ -0,0 +1,14 @@ +package provider + +import "context" + +type Request struct { + SystemPrompt string + UserPrompt string +} + +type Response struct { + Content string +} + +type ReviewFunc func(ctx context.Context, req Request) (Response, error) diff --git a/internal/review/review.go b/internal/review/review.go new file mode 100644 index 0000000..07aa60b --- /dev/null +++ b/internal/review/review.go @@ -0,0 +1,155 @@ +package review + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/monokrome/codereview/internal/diff" + "github.com/monokrome/codereview/internal/prompt" + "github.com/monokrome/codereview/internal/provider" +) + +type Config struct { + Diff string + Provider provider.ReviewFunc + Instructions string + PriorComments []prompt.PriorComment + FileContents map[string]string +} + +func Run(ctx context.Context, cfg Config) (Result, error) { + files, err := diff.Parse(cfg.Diff) + if err != nil { + return Result{}, fmt.Errorf("parsing diff: %w", err) + } + + system, user := prompt.Build(files, cfg.Instructions, cfg.PriorComments, cfg.FileContents) + + resp, err := cfg.Provider(ctx, provider.Request{ + SystemPrompt: system, + UserPrompt: user, + }) + if err != nil { + return Result{}, fmt.Errorf("calling provider: %w", err) + } + + result, err := ParseResponse(resp.Content) + if err != nil { + return Result{}, fmt.Errorf("parsing response: %w", err) + } + + result.Comments = filterValidComments(result.Comments, files) + + return result, nil +} + +type ReplyConfig struct { + Provider provider.ReviewFunc + Thread []prompt.ThreadMessage + DiffHunk string + Instructions string +} + +type ReplyResult struct { + Reply string `json:"reply"` + Resolved bool `json:"resolved"` +} + +func RunReply(ctx context.Context, cfg ReplyConfig) (ReplyResult, error) { + system, user := prompt.BuildReply(cfg.Thread, cfg.DiffHunk, cfg.Instructions) + + resp, err := cfg.Provider(ctx, provider.Request{ + SystemPrompt: system, + UserPrompt: user, + }) + if err != nil { + return ReplyResult{}, fmt.Errorf("calling provider: %w", err) + } + + cleaned := strings.TrimSpace(stripMarkdownFences(resp.Content)) + + var result ReplyResult + if err := json.Unmarshal([]byte(cleaned), &result); err != nil { + return ReplyResult{Reply: cleaned}, nil + } + + return result, nil +} + +func ParseResponse(raw string) (Result, error) { + cleaned := stripMarkdownFences(raw) + cleaned = strings.TrimSpace(cleaned) + + var result Result + if err := json.Unmarshal([]byte(cleaned), &result); err != nil { + return Result{}, fmt.Errorf("unmarshalling JSON: %w", err) + } + + return result, nil +} + +func stripMarkdownFences(s string) string { + s = strings.TrimSpace(s) + + if !strings.HasPrefix(s, "```") { + return s + } + + firstNewline := strings.Index(s, "\n") + if firstNewline < 0 { + return s + } + s = s[firstNewline+1:] + + if idx := strings.LastIndex(s, "```"); idx >= 0 { + s = s[:idx] + } + + return strings.TrimSpace(s) +} + +func filterValidComments(comments []Comment, files []diff.File) []Comment { + validPaths := make(map[string]map[int]bool) + for _, f := range files { + lines := make(map[int]bool) + for _, h := range f.Hunks { + for _, l := range h.Lines { + if l.Kind != diff.KindRemoved { + lines[l.Number] = true + } + } + } + validPaths[f.Path] = lines + } + + var valid []Comment + for _, c := range comments { + if !IsValidLabel(c.Label) { + fmt.Fprintf(os.Stderr, "warning: dropping comment with invalid label %q\n", c.Label) + continue + } + + if c.Line <= 0 { + fmt.Fprintf(os.Stderr, "warning: dropping comment with non-positive line %d\n", c.Line) + continue + } + + fileLines, ok := validPaths[c.Path] + if !ok { + fmt.Fprintf(os.Stderr, "warning: dropping comment for path %q not in diff\n", c.Path) + continue + } + + if !fileLines[c.Line] { + fmt.Fprintf(os.Stderr, "warning: dropping comment for line %d not in diff for %q\n", c.Line, c.Path) + continue + } + + valid = append(valid, c) + } + + return valid +} diff --git a/internal/review/types.go b/internal/review/types.go new file mode 100644 index 0000000..8ba0231 --- /dev/null +++ b/internal/review/types.go @@ -0,0 +1,48 @@ +package review + +type Verdict string + +const ( + VerdictApprove Verdict = "APPROVE" + VerdictRequestChanges Verdict = "REQUEST_CHANGES" + VerdictComment Verdict = "COMMENT" +) + +type Label string + +const ( + LabelNit Label = "nit" + LabelSuggestion Label = "suggestion" + LabelIssue Label = "issue" + LabelQuestion Label = "question" + LabelThought Label = "thought" + LabelChore Label = "chore" + LabelPraise Label = "praise" +) + +var validLabels = map[Label]bool{ + LabelNit: true, + LabelSuggestion: true, + LabelIssue: true, + LabelQuestion: true, + LabelThought: true, + LabelChore: true, + LabelPraise: true, +} + +func IsValidLabel(l Label) bool { + return validLabels[l] +} + +type Comment struct { + Path string `json:"path"` + Line int `json:"line"` + Label Label `json:"label"` + Body string `json:"body"` +} + +type Result struct { + Verdict Verdict `json:"verdict"` + Summary string `json:"summary"` + Comments []Comment `json:"comments"` +}