DEV Community

Cover image for 使用 Gitea Actions 與 OpenAI 實現自動化 PR Code Review
Let's Write
Let's Write

Posted on • Originally published at letswrite.tw

使用 Gitea Actions 與 OpenAI 實現自動化 PR Code Review

本篇要解決的問題

在團隊協作開發中,Code Review 是確保程式碼品質的重要環節。

但很常要審核的人,自己也在趕案子,要落實很難,如果可以每次都自動由 AI 來審,至少可以讓開發工程師藉由 AI 回饋,來知道自己哪些部份需要注意。

之前有寫過 GitHub、GitLab 的版本:

但不是每間公司都會使用外部雲端服務,也有公司是自行架設 Git 服務。

自行架設 Git 服務時,一般會採用 GitLab 或 Gitea。

本筆記文將示範如何使用 Gitea Actions 搭配 OpenAI API,建立自動化的 PR Code Review 流程。當開發者提交 Pull Request 時,系統會自動:

  1. 分析 PR 中的程式碼變更
  2. 透過 OpenAI 識別潛在的錯誤、安全問題和可維護性問題
  3. 在 PR 中自動留言,提供詳細的改善建議
  4. 發送 Discord 通知(可選)

這套自動化流程能夠:

  • 提早發現程式碼問題,減少 bug 進入主分支的機會
  • 節省人工審查時間,讓團隊專注於更複雜的邏輯檢查
  • 提供一致性的審查標準
  • 幫助新手開發者學習最佳實踐

一、安裝 Gitea

準備 docker-compose.yml

建立一個專案目錄,並建立 docker-compose.yml 檔案:

services:
  server:
    image: gitea/gitea:latest
    container_name: gitea
    restart: always
    environment:
      - USER_UID=1000
      - USER_GID=1000

      # Database config
      - GITEA__database__DB_TYPE=postgres
      - GITEA__database__HOST=db:5432
      - GITEA__database__NAME=gitea
      - GITEA__database__USER=gitea
      - GITEA__database__PASSWD=gitea123

      # Actions config
      - GITEA__actions__ENABLED=true
      - GITEA__actions__DEFAULT_ACTIONS_URL=https://github.com

    ports:
      - "8123:3000" # Web UI

    volumes:
      - ./gitea:/data

    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:15
    container_name: gitea-postgres
    restart: always
    environment:
      - POSTGRES_DB=gitea
      - POSTGRES_USER=gitea
      - POSTGRES_PASSWORD=gitea123
    volumes:
      - ./postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U gitea -d gitea"]
      interval: 10s
      timeout: 5s
      retries: 5
Enter fullscreen mode Exit fullscreen mode

關鍵設定說明

在這個 docker-compose 設定中,我們已經透過環境變數啟用了 Gitea Actions 功能:

  • GITEA__actions__ENABLED=true:啟用 Actions 功能
  • GITEA__actions__DEFAULT_ACTIONS_URL=https://github.com:設定 Actions 的預設來源為 GitHub

這樣就不需要在 UI 介面另外設定,Gitea 啟動後即可直接使用 Actions 功能。

啟動 Gitea

在專案目錄下執行:

docker-compose up -d
Enter fullscreen mode Exit fullscreen mode

等待容器啟動完成後,開啟瀏覽器訪問 http://192.168.xx.xxx:8123(請將 IP 改為我們的實際 IP 位址)。

Gitea 介面首頁畫面

完成 Gitea 的初始設定(建立管理員帳號等),即可開始使用。


二、安裝 Gitea Runner

取得 Runner Registration Token

首先需要從 Gitea 後台取得 Runner 的註冊 Token。

登入 Gitea 後,進入管理後台:

Gitea 管理後台選單畫面

在「站點管理」→「Actions」→「Runner」頁面,點選「建立新的 Runner」或查看註冊 Token:

Gitea Actions Runner 註冊 Token 取得畫面

複製這個 Token,稍後會用到。

建立 Runner 的 docker-compose.yml

在另一個目錄,建立 Runner 的 docker-compose.yml

services:
  runner:
    image: docker.io/gitea/act_runner:latest
    container_name: gitea-runner
    restart: always
    environment:
      GITEA_INSTANCE_URL: "http://192.168.68.61:8123"
      GITEA_RUNNER_REGISTRATION_TOKEN: "請替換為我們的 Token"
      GITEA_RUNNER_NAME: "code_review"

    volumes:
      - ./data:/data
      - /var/run/docker.sock:/var/run/docker.sock
Enter fullscreen mode Exit fullscreen mode

重要提醒:

  • GITEA_INSTANCE_URL 中的 IP 改為我們的實際 IP
  • GITEA_RUNNER_REGISTRATION_TOKEN 替換為剛才複製的 Token

啟動 Runner

docker-compose up -d
Enter fullscreen mode Exit fullscreen mode

確認 Runner 連線狀態

回到 Gitea 管理後台的 Runner 頁面,應該可以看到剛才啟動的 Runner 已經成功註冊並處於「閒置」狀態:

Gitea Runner 已成功連線並顯示閒置狀態

看到綠色的「閒置」狀態,表示 Runner 已經準備好接受任務。


三、建立測試專案

建立新專案並設定 Secrets

在 Gitea 上建立一個新的專案(或使用現有專案):

Gitea 介面中的建立新專案畫面

進入專案後,前往「設定」→「Actions」→「Secrets」:

Gitea 專案設定中的 Secrets 管理頁面

新增以下三個 Secrets:

  1. OPENAIAPIKEY:我們的 OpenAI API Key

Gitea Secrets 新增 OPENAIAPIKEY 介面

  1. GITEATOKEN:Gitea 的 Access Token(用於自動留言)

在「設定」→「應用程式」→「管理存取令牌」中產生。

設定令牌權限時:

  • issue:讀取和寫入
  • repository:讀取

再把產生的 Token 存到 Secrets 裡。

Gitea Access Token 產生畫面

  1. DISCORDWEBHOOK:Discord Webhook URL(可選)

新增完後,Secrets 管理裡最多就會看到三組設定好的清單:

Gitea 專案內已設定的 Secrets 清單

新增 Workflow 檔案

在要執行 Code Review 的專案根目錄建立 .gitea/workflows/ 目錄結構,並在其中新增 openai-pr-review.yml 檔案:

your-project/
├── .gitea/
│   └── workflows/
│       └── openai-pr-review.yml
└── (其他專案檔案)
Enter fullscreen mode Exit fullscreen mode

openai-pr-review.yml 內容如下:

name: OpenAI PR Code Review

on:
  pull_request:
    types: [opened, synchronize]

jobs:
  pr-review:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Generate PR diff
        run: |
          git fetch origin pull/${{ github.event.pull_request.number }}/head:pr_head
          git diff --diff-filter=AM origin/${{ github.event.pull_request.base.ref }}...pr_head > diff.txt
          echo "Diff generated."

      - name: Review PR with OpenAI and post results
        uses: actions/github-script@v7
        env:
          OPENAI_API_KEY: ${{ secrets.OPENAIAPIKEY }}
          GITEA_TOKEN: ${{ secrets.GITEATOKEN }}
          DISCORD_WEBHOOK: ${{ secrets.DISCORDWEBHOOK }}
          SERVER_URL: ${{ github.server_url }}
          REPO: ${{ github.repository }}
          PR_NUMBER: ${{ github.event.pull_request.number }}
          PR_TITLE: ${{ github.event.pull_request.title }}
          PR_AUTHOR: ${{ github.event.pull_request.user.login }}
        with:
          script: |
            const fs = require("fs");

            const apiKey = process.env.OPENAI_API_KEY;
            const giteaToken = process.env.GITEA_TOKEN;
            const discordWebhook = process.env.DISCORD_WEBHOOK;
            const serverUrl = process.env.SERVER_URL;
            const repo = process.env.REPO;
            const prNumber = process.env.PR_NUMBER;
            const prTitle = process.env.PR_TITLE;
            const prAuthor = process.env.PR_AUTHOR;

            if (!apiKey) {
              core.setFailed("缺少 OPENAI_API_KEY");
              return;
            }

            // 讀取 diff
            let diff = "";
            try {
              diff = fs.readFileSync("diff.txt", "utf8");
            } catch (e) {
              core.info("無法讀取 diff.txt");
              diff = "";
            }
            diff = diff.slice(0, 12000);

            if (!diff.trim()) {
              core.info("No diff to review.");
              return;
            }

            // 精簡版 system prompt
            const systemPrompt = [
              "你是專業 code reviewer,請根據 PR diff 找出『最重要的 5 項問題』,依優先順序:1. 錯誤 2. 資安 3. 可維護性。",
              "找不到重要問題時,只回覆:**No major issues found.**(Markdown)。",
              "",
              "# Findings Summary (Top 5)",
              "請產生一份 Markdown 表格:",
              "| # | 分類 | 問題摘要 | Risk | 建議摘要 |",
              "|---|------|----------|------|-----------|",
              "Risk = High / Medium / Low。",
              "",
              "# Detailed Review",
              "依序對每項輸出:",
              "## 問題 X(分類)",
              "### 問題摘要",
              "- 1~2 句說明",
              "### Risk Score",
              "- High / Medium / Low",
              "### 為何重要",
              "- 說明風險與影響",
              "### 改善建議",
              "若有程式碼,請提供『原始程式碼』與『建議後程式碼』兩段 Markdown 程式碼區塊,使用 diff 語法。",
              "若無程式碼,提供可執行條列建議。",
              "",
              "請:",
              "- 只輸出最重要的五項(不足五項則寫實際數量)",
              "- 嚴格使用 Markdown",
              "- 不要產生前言、後記、寒暄或多餘敘述",
              "- 回覆簡潔、可採取行動"
            ].join("\n");

            const payload = {
              model: "gpt-5-nano",
              reasoning_effort: "low",
              messages: [
                {
                  role: "system",
                  content: systemPrompt
                },
                {
                  role: "user",
                  content: `請 review 以下 PR diff:\n\n${diff}`
                }
              ]
            };

            core.info("Calling OpenAI...");

            const openaiRes = await fetch("https://api.openai.com/v1/chat/completions", {
              method: "POST",
              headers: {
                "Authorization": `Bearer ${apiKey}`,
                "Content-Type": "application/json"
              },
              body: JSON.stringify(payload)
            });

            if (!openaiRes.ok) {
              const text = await openaiRes.text();
              core.setFailed(`OpenAI API Error: ${openaiRes.status} ${text}`);
              return;
            }

            const data = await openaiRes.json();
            const reviewContent =
              data.choices?.[0]?.message?.content?.trim() || "無法取得回應";

            core.info("OpenAI review completed.");
            core.info("Preview (first 200 chars):");
            core.info(reviewContent.slice(0, 200));

            // ===== 把結果貼到 Gitea PR comment =====
            if (giteaToken && serverUrl && repo && prNumber) {
              const apiUrl = `${serverUrl}/api/v1/repos/${repo}/issues/${prNumber}/comments`;
              const body = {
                body: `### 🤖 OpenAI Code Review\n\n${reviewContent}`
              };

              core.info(`Posting review comment to: ${apiUrl}`);

              const res = await fetch(apiUrl, {
                method: "POST",
                headers: {
                  "Authorization": `token ${giteaToken}`,
                  "Content-Type": "application/json"
                },
                body: JSON.stringify(body)
              });

              if (!res.ok) {
                const text = await res.text();
                core.setFailed(`Failed to post comment: ${res.status} ${text}`);
                return;
              } else {
                core.info("Comment posted successfully.");
              }
            } else {
              core.info("GITEA_TOKEN / SERVER_URL / REPO / PR_NUMBER 不完整,略過貼 PR comment。");
            }

            // ===== 發 Discord 通知 =====
            if (discordWebhook) {
              const message =
                `🤖 **Code Review 完成**\n\n` +
                `OpenAI 已完成程式碼審查,請前往查看建議。\n\n` +
                `標題:${prTitle}\n` +
                `作者:${prAuthor}\n` +
                `連結:${serverUrl}/${repo}/pulls/${prNumber}`;

              const discordRes = await fetch(discordWebhook, {
                method: "POST",
                headers: {
                  "Content-Type": "application/json"
                },
                body: JSON.stringify({ content: message })
              });

              if (!discordRes.ok) {
                const text = await discordRes.text();
                core.setFailed(`Failed to send Discord notification: ${discordRes.status} ${text}`);
              } else {
                core.info("Discord notification sent.");
              }
            } else {
              core.info("No DISCORD_WEBHOOK provided, skip Discord notification.");
            }
Enter fullscreen mode Exit fullscreen mode

將此 workflow 檔案 commit 到主分支(main 或 master)。

新增分支

建立一個新的開發分支來進行測試,可以用介面操作,也可以下指令:

git checkout -b feature/test-review
Enter fullscreen mode Exit fullscreen mode

建立測試用 JS 檔

在專案根目錄建立 index.js,並故意寫入一些常見的錯誤:

// 測試用程式碼 - 包含一些常見問題
function calculateTotal(price, quantity) {
  // 問題 1:缺少參數驗證
  var total = price * quantity;

  // 問題 2:使用 var 而非 let/const
  var discount = 0.1;

  // 問題 3:可能的精度問題
  return total - total * discount;
}

// 問題 4:未處理的 Promise
fetch("https://api.example.com/data").then((response) => response.json());

// 問題 5:使用 == 而非 ===
if (calculateTotal(100, 2) == 180) {
  console.log("計算正確");
}
Enter fullscreen mode Exit fullscreen mode

這個範例包含了多個常見問題,OpenAI 應該能夠識別並提供改善建議。

Commit 並建立 Pull Request

將變更 commit 並推送到遠端,可以用介面操作,也可以下指令:

git add index.js
git commit -m "Add test file for code review"
git push origin feature/test-review
Enter fullscreen mode Exit fullscreen mode

回到 Gitea 介面,建立一個從 feature/test-reviewmain 的 Pull Request:

Gitea 介面中的建立 Pull Request 畫面

填寫 PR 標題和描述後,點選「建立合併請求」:

建立合併請求按鈕畫面

在 Gitea 上查看結果

PR 建立後,Gitea Actions 會自動觸發。我們可以在以下位置查看執行狀態:

1. Actions 執行狀態

在 PR 頁面下方可以看到 Actions 的執行狀態:

Gitea Actions 在 Pull Request 中的執行狀態

點選可以查看詳細的執行 log:

Gitea Actions 執行 log 詳細畫面

第一次執行會比較久,後續再執行就會比較快。

2. OpenAI 的 Review 結果

當 Actions 執行完成後,OpenAI 會在 PR 中自動留言,提供詳細的程式碼審查報告:

OpenAI 自動在 Pull Request 中留言審查結果

Review 報告會包含:

  • Findings Summary 表格:列出前 5 大問題的摘要。
  • Detailed Review:每個問題的詳細說明,包括風險評估和改善建議。
  • 程式碼範例:使用 diff 格式展示建議的修改。

3. Discord 通知(若有設定)

如果有設定 Discord Webhook,團隊成員也會在 Discord 頻道收到通知:

Discord 收到來自 Gitea PR Code Review 的通知訊息


結語

透過這套自動化流程,我們可以在每次 Pull Request 時獲得即時的程式碼審查建議。

雖然 AI 無法完全取代人工審查,但它能有效地:

  • 捕捉常見的程式碼問題和 anti-patterns。
  • 提供一致性的審查標準。
  • 節省人工審查的時間。
  • 作為程式碼品質的第一道防線。

如果將 AI 審查作為輔助工具,在重要的 PR 中搭配人工審查,確保程式碼品質和業務邏輯的正確性。

Top comments (0)