본문 바로가기
AI

AI 코드 리뷰 봇 만들기: Bitbucket과 OpenRouter 연동 가이드

by CodeViber 2025. 7. 5.

Bitbucket을 위한 AI 코드 리뷰 봇을 직접 만들어 보세요. 이 가이드에서는 OpenRouter의 무료 API와 Jenkins 서버를 연동하여, Pull Request(PR)가 생성될 때마다 코드의 문제점을 자동으로 찾아주는 똑똑한 리뷰 자동화 시스템을 구축하는 전 과정을 다룹니다. 이제 단순 반복 작업은 봇에게 맡기고 더 중요한 일에 집중하세요.


 

코드 리뷰는 협업의 필수 과정이지만 솔직히 지루하고 반복적일 때가 많습니다. 처음엔 의욕적으로 시작하지만, 어느새 습관적으로 Approve 버튼을 누르거나 간단한 오타만 찾아내고 있지는 않으신가요?

 

단순 오탈자 찾기, 문법 오류, 컴파일 에러 찾기 등 대 LLM 시대에 이런 단순 반복 작업을 시간을 할애해가며 수동으로 처리하는 것이 비효율적으로 느껴졌습니다. 유료 플러그인도 있지만, 마침 놀고 있던 젠킨스 서버와 OpenRouter의 무료 모델을 활용하면 비용 없이 우리 팀만의 코드 리뷰 봇을 만들 수 있겠다는 생각이 들었습니다.

1. OpenRouter 무료 모델 설정: 리뷰를 수행할 AI 준비하기

가장 먼저 코드 리뷰를 수행할 LLM을 준비합니다. OpenRouter는 다양한 LLM을 하나의 API로 사용할 수 있게 해주는 편리한 플랫폼이며, 무료로 사용할 수 있는 모델도 제공합니다.

  1. OpenRouter 웹사이트에 가입하고 API 키를 발급받습니다.
  2. 아래와 같이 발급받은 키와 사용할 모델을 환경 변수로 등록합니다.
OPENROUTER_API_KEY=sk-or-v1-abc...
LLM_MODEL=deepseek/deepseek-chat-v3-0324:free

deepseek/deepseek-chat-v3-0324:free 모델은 코드 생성 및 이해 능력이 뛰어나고 무료로 사용할 수 있어 추천합니다.

사내 코드를 외부에 노출하는 것이 민감한 경우, 사용하는 LLM의 데이터 정책을 반드시 확인해야 합니다. 일부 모델은 사용자 데이터를 학습에 활용할 수 있습니다.

 

2. Bitbucket Server REST API 분석: 봇의 눈과 입 만들기

Bitbucket Server는 다양한 리소스에 접근할 수 있도록 강력한 REST API를 제공합니다. 우리 봇은 이 API를 통해 PR의 변경 사항을 '보고', 리뷰 결과를 '말하게' 됩니다. 핵심적인 API는 두 가지입니다.

Pull Request Diff API: 변경된 코드 가져오기

GET /rest/api/1.0/projects/{projectKey}/repos/{repoSlug}/pull-requests/{pullRequestId}/diff

PR에 포함된 모든 코드 변경사항(diff)을 JSON 형태로 상세하게 알려줍니다. 우리는 이 데이터에서 실제 코드 변경 내용만 추출하여 LLM에게 전달할 것입니다. 응답 데이터 구조는 복잡하지만, 핵심은 diffs 배열 안에 있습니다.

{
  "fromHash": "2b75c36ef145c7033f3d93e2f8fcac6f5b849408",
  "toHash": "60796aa3f67a7cd15073b8b384b63d926f073001",
  "contextLines": 10,
  "whitespace": "SHOW",
  "diffs": [
    {
      "source": null,
      "destination": {
        "components": [
          "src", "main", "java", "com", "h2m", "weddingbook", "admin", "consulting", "dao", "ConsultingDao.java"
        ],
        "parent": "src/main/java/com/h2m/weddingbook/admin/consulting/dao",
        "name": "ConsultingDao.java",
        "extension": "java",
        "toString": "src/main/java/com/h2m/weddingbook/admin/consulting/dao/ConsultingDao.java"
      },
      "hunks": [
        {
          "sourceLine": 0,
          "sourceSpan": 0,
          "destinationLine": 1,
          "destinationSpan": 30,
          "segments": [
            {
              "type": "ADDED",
              "lines": [
                {
                  "source": 0,
                  "destination": 1,
                  "line": "package com.h2m.weddingbook.admin.consulting.dao;",
                  "truncated": false
                }
              ]
            }
          ]
        }
      ]
    }
  ]
}

 

Pull Request Comment API: 리뷰 결과 댓글 달기

POST /rest/api/1.0/projects/{projectKey}/repos/{repoSlug}/pull-requests/{pullRequestId}/comments

LLM이 생성한 리뷰 내용을 PR에 댓글로 작성할 때 사용합니다. 요청 본문에 { "text": "리뷰 내용..." } 형식으로 전송하면 됩니다.

3. Bitbucket 웹훅: PR 생성을 봇에게 알리기

Bitbucket에서 PR이 생성될 때마다 우리가 만든 봇(서버)에게 알려주도록 웹훅을 설정해야 합니다.

  • Bitbucket 프로젝트 설정 > 웹훅 > 웹훅 생성
  • URL: http://<젠킨스 또는 봇 서버 IP>:<포트>/webhook
  • 이벤트: Pull request > Opened

웹훅이 트리거되면, Bitbucket은 아래와 같은 JSON 데이터를 우리 서버로 전송합니다. 이 데이터를 파싱해서 PR의 ID, 제목, 브랜치 정보 등을 얻을 수 있습니다.

 

PR 웹훅의 본문으로 들어오는 데이터는 다음과 같습니다.

{
  "eventKey": "pr:opened",
  "pullRequest": {
    "id": 2177,
    "title": "PR 제목",
    "description": "PR 본문",
    "fromRef": {
      "displayId": "feature/2145",
      "repository": {
        "slug": "wbapp-admin",
        "project": { "key": "SERVER" }
      }
    },
    "toRef": {
      "displayId": "develop",
      "repository": {
        "slug": "project1",
        "project": { "key": "SERVER" }
      }
    },
    "author": { "user": { "displayName": "jcheo" } },
    "links": { "self": [{ "href": "http://bitbucket/pr/2177" }] }
  }
}

 

위 데이터를 pr-event.json 파일로 저장한 뒤, 간단한 노드 서버로 이를 테스트해봅니다.

import http from 'http';

const PORT = process.env.PORT || 3000;

const server = http.createServer(async (req, res) => {
  if (req.method === 'GET' && req.url === '/health') {
    res.writeHead(200, { 'Content-Type': 'text/plain' });
    res.end('OK');
    return;
  }
  
if (req.method === 'POST' && req.url === '/webhook') {
    let body = '';
    req.on('data', chunk => { body += chunk; });
    req.on('end', async () => {
      try {
        console.log('--- [PR BOT] Webhook Received ---');
        console.log('Request body:', body);
      } catch (e) {
        console.error('Error in webhook handler:', e);
        res.writeHead(400);
        res.end('Invalid JSON');
      }
    });
  }
});


server.listen(PORT, () => {
  console.log(`Bitbucket PR Bot listening on port ${PORT}`);
});

 

해당 서버를 실행한 뒤 curl -X POST http://localhost:3000/webhook -H "Content-Type: application/json" --data-binary @pr-event.json 명령어로 웹훅 데이터가 정확히 들어오는지 확인합니다.

 

로그 예시

> bitbucket-pr-bot@1.0.0 dev2
> env-cmd -f .env ts-node src/test.ts

Bitbucket PR Bot listening on port 3000
--- [PR BOT] Webhook Received ---
Request body: {
  "eventKey": "pr:opened",
  "pullRequest": {
    "id": 2177,
    "title": "[WWP-2145] feature : 일괄 정산 변경 및 로그 / 어드민 메모 API",
    "description": "[WWP-2189] feature : 즉시 예약 결제 관리 및 모달 내 프로퍼티 추가 및 하기 기능 추가\n* 일괄 정산 상태 변경\n* 관 
리자 메모 저장",
    "fromRef": {
      "displayId": "feature/WWP-2145",
      "repository": {
        "slug": "wbapp-admin",
        "project": { "key": "SERVER" }
      }
    },
    "toRef": {
      "displayId": "develop",
      "repository": {
        "slug": "wbapp-admin",
        "project": { "key": "SERVER" }
      }
    },
    "author": { "user": { "displayName": "ym.park" } },
    "links": { "self": [{ "href": "http://bitbucket/pr/2177" }] }
  }
}

4. AI 리뷰 봇 구현: 핵심 로직 조립하기

이제 모든 준비가 끝났습니다. 웹훅을 수신하고, Bitbucket API로 diff를 가져와, LLM으로 리뷰한 뒤, 다시 Bitbucket API로 댓글을 다는 핵심 로직을 코드로 구현해 보겠습니다.

A. 웹훅 데이터 파싱

웹훅으로 받은 데이터에서 pullRequestId, title, description 등 리뷰에 필요한 핵심 정보를 추출합니다. 특히 pullRequestId는 diff를 조회하고 댓글을 다는 데 사용되는 열쇠입니다.

export function extractIdentifers(event: any) {
  if (!event || !event.pullRequest) return null;
  const pr = event.pullRequest;
  return {
    to: {
      projectKey: pr.toRef?.repository?.project?.key,
      repoSlug: pr.toRef?.repository?.slug,
    },
    from: {
      projectKey: pr.fromRef?.repository?.project?.key,
      repoSlug: pr.fromRef?.repository?.slug,
    },
    pullRequestId: pr.id,
  };
}

export function parsePROpenedWebook(body: any) {
  if (!body || !body.pullRequest) return null;
  const pr = body.pullRequest;
  return {
    id: pr.id,
    title: pr.title,
    description: pr.description,
    fromRef: pr.fromRef?.displayId,
    toRef: pr.toRef?.displayId,
    author: pr.author?.user?.displayName,
    url: pr.links?.self?.[0]?.href,
  };
}

B. 변경사항(Diff) 추출

파싱한 pullRequestId를 이용해 위에서 살펴본 Diff API를 호출합니다.

export async function getPRDiff({
  baseUrl,
  projectKey,
  repoSlug,
  pullRequestId,
  bitbucketToken
}: BitbucketPRParams): Promise<any> {
  const url = `${baseUrl}/rest/api/1.0/projects/${projectKey}/repos/${repoSlug}/pull-requests/${pullRequestId}/diff`;
  try {
    const response = await fetch(url, {
      headers: {
        'Authorization': `Bearer ${bitbucketToken}`,
        'Content-Type': 'application/json',
      },
    });
    if (!response.ok) {
      const errText = await response.text().catch(() => '');
      throw new Error(`Bitbucket diff API call failed: ${response.status} ${response.statusText} ${errText} (URL: ${url})`);
    }
    return await response.json();
  } catch (err: any) {
    throw new Error(`Bitbucket diff API network/parsing error: ${err.message} (URL: ${url})`);
  }
}

 

C. LLM 프롬프트 구성 및 호출

이 부분이 봇의 성능을 좌우합니다. PR 정보와 코드 변경사항(diff)을 조합하여 LLM이 양질의 리뷰를 생성하도록 프롬프트를 신중하게 설계해야 합니다.

export function buildReviewPrompt({ title, description, diff }: { /* ... */ }): string {
  return [
    `다음 Pull Request 정보를 바탕으로 리뷰를 작성해주세요.`,
    `[PR 제목]: ${title}`,
    `[PR 본문]: ${description || '내용 없음'}`,
    `\n--- [코드 변경사항] ---\n${diff}\n--- [코드 변경사항 끝] ---`,
    `\n[리뷰 요청사항]`,
    `- 오타, 문법 오류, 잠재적인 런타임 에러를 찾아주세요.`,
    `- 코드 스타일, 성능, 보안 측면에서 개선할 점이 있다면 제안해주세요.`,
    `- 리뷰는 친절하고 명확한 어조로 작성해주세요.`,
  ].join('\n\n');
}

export class OpenRouter implements LLMProvider {
  private apiKey: string;
  private model: string;

  constructor(apiKey: string, model: string) {
    this.apiKey = apiKey;
    this.model = model;
  }

  async reviewPullRequest({ title, description, diff}: { title: string; description: string; diff: string }): Promise<{ content: string }> {
    const prompt = buildReviewPrompt({ title, description, diff });
    let body: any = {
      model: this.model,
      messages: [
        { role: 'system', content: '당신은 시니어 개발자입니다.' },
        { role: 'user', content: prompt }
      ]
    };
    const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${this.apiKey}`,
        'Content-Type': 'application/json',
        'HTTP-Referer': process.env.BITBUCKET_BASE_URL || '',
        'X-Title': 'Bitbucket PR Bot',
      },
      body: JSON.stringify(body)
    });
    if (!response.ok) {
      const errText = await response.text().catch(() => '');
      console.error('Fail call to LLM API:', response.status, response.statusText, errText);
      throw new Error('Fail call to LLM API');
    }
    const data = await response.json();
    const content = data.choices?.[0]?.message?.content || 'No review result';
    return { content };
  }
}

D. PR에 리뷰 코멘트 작성

LLM의 답변을 Comment API를 통해 PR에 게시합니다. 봇이 작성했음을 알리도록 [PR BOT] 같은 접두사를 붙여주는 것이 좋습니다.

export async function postPRComment({
  baseUrl,
  projectKey,
  repoSlug,
  pullRequestId,
  bitbucketToken,
  content
}: BitbucketPRParams & { content: string }): Promise<any> {
  const url = `${baseUrl}/rest/api/1.0/projects/${projectKey}/repos/${repoSlug}/pull-requests/${pullRequestId}/comments`;
  let commentText = '';
  commentText += '[PR BOT]\n' + content;
  try {
    const response = await fetch(url, {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${bitbucketToken}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ text: commentText })
    });
    if (!response.ok) {
      const errText = await response.text().catch(() => '');
      throw new Error(`Bitbucket comment API call failed: ${response.status} ${response.statusText} ${errText}`);
    }
    return await response.json();
  } catch (err: any) {
    throw new Error(`Bitbucket comment API network/parsing error: ${err.message}`);
  }
}

마무리하며

지금까지 Bitbucket과 OpenRouter를 활용해 간단한 AI 코드 리뷰 봇을 만들어 보았습니다. 여기서 더 나아가 특정 브랜치에만 봇을 적용하거나, 코드 스타일에 대한 규칙을 프롬프트에 더 구체적으로 명시하는 등 팀의 필요에 맞게 얼마든지 커스터마이징할 수 있습니다.

 

재미있는 점은, 이 포스트에서 소개한 봇의 상당 부분 코드를 GitHub Copilot의 도움을 받아 작성했다는 것입니다. AI를 이용해 AI 봇을 만드는 시대가 온 것이죠. 개발자에게 주어진 강력한 도구를 활용해 반복적인 업무는 자동화하고, 우리 모두 더 창의적인 문제에 집중하는 시간을 늘려보는 것은 어떨까요?