2026年7月3日金曜日

Issue を作成したらあとは AI 先生に修正してもらえばいいじゃない

Issue を作成したらあとは AI 先生に修正してもらえばいいじゃない

Codex Skills 編

環境

  • Ubuntu 24.04
  • codex 0.142.5
  • Gitlab 18.11.6

仕組み

目的: GitLab の Issue を自動検知し、AI(Codex)を使ってコード修正〜MR作成までを自動化するエージェント。


構成要素

ファイル 役割
main.py エントリポイント。初期化してスケジューラを起動
scheduler.py 定期ポーリングループ。Issue の処理を制御
mygitlab.py GitLab API から agent ラベルの Issue を取得
state.py SQLite で処理済み Issue を管理(二重処理防止)
codex.py Codex CLI をサブプロセスで起動し AI エージェントを実行
codex_skills/ Codex に渡すスキル定義(修正・テスト・コミット・MR作成)
config.py 全設定値の一元管理

動作の流れ(概要)

  1. Issue 検知: POLL_INTERVAL(60秒)おきに GitLab をポーリングし、agent ラベルの付いた Issue を取得
  2. 重複防止: 取得した Issue が SQLite に記録済みかチェックし、処理済みならスキップ
  3. AI 修正: 未処理の Issue に対して Codex CLI を起動。gitlab-issue-agent スキルに Issue の情報(ID・タイトル・説明)を渡す
  4. Codex の自律作業: Codex がスキルの指示に従い、コード修正 → テスト実行 → コミット → MR 作成を自律的に実施
  5. 完了記録: 処理が終わった Issue ID を SQLite に保存し、次のサイクルへ

ポイント

  • 冪等性: SQLite による処理済み管理で同じ Issue を何度も処理しない
  • 疎結合: Codex とのやり取りは環境変数とプロンプトのみで、スキルの追加・変更が容易
  • 自律性: MR 作成まで人手不要。レビューアー・アサインも自動設定済み

流れ

基本は対象の Issue 分ループして次々修正し MR を作成します

サンプルコード

ツリー

.
├── codex_skills
│   ├── create-mr
│   │   ├── scripts
│   │   │   └── create_mr.py
│   │   └── SKILL.md
│   ├── git-commit
│   │   └── SKILL.md
│   ├── gitlab-issue-agent
│   │   └── SKILL.md
│   └── run-tests
│       └── SKILL.md
├── codex.py
├── config.py
├── logger.py
├── main.py
├── mygitlab.py
├── README.md
├── scheduler.py
├── state.db
├── state.py
└── systemd
    └── codex-agent.service

main.py

import logging

from logger import setup_logger
from scheduler import run
from state import init_db

logger = logging.getLogger(__name__)

if __name__ == "__main__":
    setup_logger()
    init_db()
    logger.info("Starting gitlab bug fix agent")
    try:
        run()
    except Exception:
        logger.exception("Agent stopped due to an unhandled exception")
        raise

scheduler.py

import logging
import time

from codex import run_codex
from config import POLL_INTERVAL
from mygitlab import get_issues
from state import is_processed, mark_processed

logger = logging.getLogger(__name__)


def run():
    logger.info("Scheduler started")
    while True:
        logger.info("Polling GitLab issues")
        issues = get_issues()
        logger.info("Fetched %d issues", len(issues))

        for issue in issues:
            iid = issue["iid"]

            if is_processed(iid):
                logger.info("Skipping already processed issue %s", iid)
                continue

            logger.info("Processing issue %s: %s", iid, issue.get("title", ""))
            run_codex(issue)

            mark_processed(iid)
            logger.info("Marked issue %s as processed", iid)

        logger.info("Polling complete; sleeping for %s seconds", POLL_INTERVAL)
        time.sleep(POLL_INTERVAL)

codex.py

import logging
import os
import subprocess

from config import (
    CODEX_PATH,
    GITLAB_TOKEN,
    GITLAB_URL,
    MR_ASSIGNEE_ID,
    MR_REVIEWER_IDS,
    PROFILE,
    PROJECT_ID,
    REPO_PATH,
    SANDBOX,
    SONNET_API_KEY,
)

logger = logging.getLogger(__name__)


def run_codex(issue):
    env = os.environ.copy()
    issue_id = issue["iid"]

    # 環境変数設定(codex_skills/create-mr/scripts/create_mr.py で使用)
    env["ISSUE_ID"] = str(issue_id)  # MR のブランチ名生成に使用
    env["ISSUE_TITLE"] = issue["title"]  # MR のタイトル生成に使用
    env["GITLAB_URL"] = GITLAB_URL  # GitLab API エンドポイント
    env["GITLAB_TOKEN"] = GITLAB_TOKEN  # GitLab API 認証
    env["PROJECT_ID"] = str(PROJECT_ID)  # MR 作成対象プロジェクト
    env["MR_ASSIGNEE_ID"] = str(MR_ASSIGNEE_ID)  # MR アサイン対象ユーザー
    env["MR_REVIEWER_IDS"] = ",".join(map(str, MR_REVIEWER_IDS))  # MR レビュアー
    env["SONNET_API_KEY"] = SONNET_API_KEY  # Codex CLI が使用する LLM API キー

    logger.info("Launching Codex for issue %s", issue_id)

    prompt = f"""
Use the skill gitlab-issue-agent.

Issue ID: {issue_id}
Title: {issue['title']}
Description: {issue['description']}
"""

    result = subprocess.run(
        [
            CODEX_PATH,
            "exec",
            "--cd",
            REPO_PATH,
            "--profile",
            PROFILE,
            "--sandbox",
            SANDBOX,
            prompt,
        ],
        env=env,
        check=True,
        capture_output=True,
        text=True,
    )

    if result.stdout:
        logger.info("Codex stdout:\n%s", result.stdout)
    if result.stderr:
        logger.warning("Codex stderr:\n%s", result.stderr)

    logger.info("Codex finished for issue %s", issue_id)

config.py

GITLAB_URL = "https://your-gitlab-url"
GITLAB_TOKEN = "glpat-xxx"
PROJECT_ID = 12345
TARGET_LABEL = "agent"

REPO_PATH = "/home/user/target"
CODEX_PATH = "/home/user/.local/bin/codex"
PROFILE = "sonnet"
SONNET_API_KEY = "dummy"
SANDBOX = "danger-full-access"

POLL_INTERVAL = 60

MR_ASSIGNEE_ID = 1
MR_REVIEWER_IDS = [2]

DB_PATH = "./path/to/state.db"
LOG_PATH = "./path/to/agent.log"

state.py

import logging
import sqlite3

from config import DB_PATH

logger = logging.getLogger(__name__)


def init_db():
    logger.info("Initializing state database at %s", DB_PATH)
    conn = sqlite3.connect(DB_PATH)
    c = conn.cursor()
    c.execute("""
    CREATE TABLE IF NOT EXISTS processed_issues (
        issue_id INTEGER PRIMARY KEY
    )
    """)
    conn.commit()
    conn.close()
    logger.info("State database is ready")


def is_processed(issue_id: int) -> bool:
    conn = sqlite3.connect(DB_PATH)
    c = conn.cursor()
    c.execute("SELECT 1 FROM processed_issues WHERE issue_id=?", (issue_id,))
    result = c.fetchone()
    conn.close()
    return result is not None


def mark_processed(issue_id: int):
    conn = sqlite3.connect(DB_PATH)
    c = conn.cursor()
    c.execute("INSERT INTO processed_issues(issue_id) VALUES(?)", (issue_id,))
    conn.commit()
    conn.close()
    logger.info("Persisted processed issue %s", issue_id)

logger.py

import logging

from config import LOG_PATH


def setup_logger():
    logging.basicConfig(
        filename=LOG_PATH,
        level=logging.INFO,
        format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
    )

mygitlab.py

import logging

import requests
from config import GITLAB_TOKEN, GITLAB_URL, PROJECT_ID, TARGET_LABEL

HEADERS = {"PRIVATE-TOKEN": GITLAB_TOKEN}

logger = logging.getLogger(__name__)


def get_issues():
    url = f"{GITLAB_URL}/api/v4/projects/{PROJECT_ID}/issues"
    params = {"labels": TARGET_LABEL, "state": "opened"}
    logger.info(
        "Fetching issues from GitLab project %s with label %s", PROJECT_ID, TARGET_LABEL
    )
    response = requests.get(url, headers=HEADERS, params=params)
    issues = response.json()
    logger.info("GitLab issues fetch completed with status %s", response.status_code)
    return issues

codex_skills/create-mr/SKILL.md

---
name: create-mr
description: Create a GitLab merge request.
---

Run the script:

python .codex/skills/create-mr/scripts/create_mr.py

codex_skills/create-mr/scripts/create_mr.py

import logging
import os
import subprocess
import sys
from pathlib import Path

import requests

SCRIPT_ROOT = Path(__file__).resolve().parents[3]
if str(SCRIPT_ROOT) not in sys.path:
    sys.path.append(str(SCRIPT_ROOT))

from logger import setup_logger

setup_logger()
logger = logging.getLogger(__name__)

issue_id = os.environ.get("ISSUE_ID")

branch = f"agent/issue-{issue_id}"

GITLAB_URL = os.environ["GITLAB_URL"]
TOKEN = os.environ["GITLAB_TOKEN"]
PROJECT_ID = os.environ["PROJECT_ID"]
MR_ASSIGNEE_ID = int(os.environ["MR_ASSIGNEE_ID"])
MR_REVIEWER_IDS = [int(x) for x in os.environ["MR_REVIEWER_IDS"].split(",")]

url = f"{GITLAB_URL}/api/v4/projects/{PROJECT_ID}/merge_requests"

headers = {"PRIVATE-TOKEN": TOKEN}

issue_title = os.environ.get("ISSUE_TITLE", f"Issue {issue_id}")

data = {
    "source_branch": branch,
    "target_branch": "main",
    "title": f"修正: {issue_title}",
    "assigned_to_id": MR_ASSIGNEE_ID,
    "reviewer_ids": MR_REVIEWER_IDS,
}

logger.info("Creating merge request for issue %s on branch %s", issue_id, branch)
response = requests.post(url, headers=headers, json=data)
logger.info("Merge request creation completed with status %s", response.status_code)

codex_skills/git-commit/SKILL.md

---
name: git-commit
description: Commit and push changes to a new branch.
---

Steps:

1. Create branch:
   agent/issue-<ISSUE_ID>

2. Commit:
   git add .
   git commit -m "fix: issue <ISSUE_ID>"

3. Push:
   git push -u origin <branch>

codex_skills/gitlab-issue-agent/SKILL.md

---
name: gitlab-issue-agent
description: End-to-end agent that processes a GitLab issue, applies fixes, runs tests, and creates a merge request.
---

You are an autonomous software engineering agent.

You will be given:

- Issue ID
- Title
- Description

# Your job

## Step 1: Understand the issue

- Identify the root cause
- Determine what needs to be fixed

## Step 2: Create a plan

Break down into tasks:

- files to modify
- logic changes
- tests needed

## Step 3: Implement fix

- Modify the codebase
- Keep changes minimal and clean

## Step 4: Run tests

Use the skill:

- run-tests

If tests fail:

- fix the issue
- retry until success (max 3 times)

## Step 5: Commit changes

Use skill:

- git-commit

## Step 6: Create MR

Use skill:

- create-mr

# Rules

- Do not break existing functionality
- Follow project conventions
- Prefer small commits

codex_skills/run-tests/SKILL.md

---
name: run-tests
description: Run Go validation and test suite.
---

Execute:

- go fmt ./...
- go vet ./...
- go build ./...
- go test ./...

If any step fails:

- Analyze the error
- Fix the issue
- Re-run

使い方

  • codex のインストールと各種設定
  • codex_skills にある各種 skill を .codex/skills 配下にコピー
  • config.pyの編集
  • codex_skills/run-tests/SKILL.md の編集
  • un run python main.py
  • Issue を作成し適切なラベルを振る

課題

  • Issue にしっかりと TODO ややるべきこと課題を記載する必要がある
    • Issue の内容が適当すぎると MR の修正内容ももうまく行かない
    • Issue のタスクが大きすぎるとうまくいかない
    • 可能な限り細かく TODO を書きつつ粒度を細かくする必要がある
  • main ブランチの pull タイミングをどうするか
    • 次の Issue を捌く前に一旦 main に戻るかそのブランチから続きを開発したいか
    • 前に作成した MR がマージされるまで次の Issue を捌くのを待つか
  • サンドボックス環境なのでネットワークや書き込み権限をしっかり与える必要がある
    • Codex でネットワーク操作 (今回だと push と MR 作成) をする場合にはほぼほぼ danger-full-access が必須そう
    • デフォルトは workspace-write なので書き込めるだけ、コードを修正したりなど (参考)
    • ちなみに Ubuntu だと bubblewrap を使ってサンドボックス化するのでインストールと sysctl の設定が必要
    • sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
    • セキュリティ的なことを考えるとインタラクティブモードが一番良さそう
    • codex を実行する環境自体をサンドボックス(コンテナや特別なVM)にする
  • 現状は 1Issue - 1MR になっている
    • AI が捌けるタスクの粒度上 Issue は可能な限り小さなタスクにする必要がある
    • ただ実際の現場だと MR に複数の Issue 対応を追加したいケースがある
    • MR が大量になってしまうのでレビューが大変になる (これも AI にお任せでいいかもしれないが)
  • スケジューラ側から codex 側に値を渡すのに環境を使っているができれば別の仕組みを考えたい
  • 処理した Issue かどうかの判断を sqlite で行っているが sqlite を使わずに別のラベルに付け替えるだけでも良さそう
    • sqlite にしておけば履歴的なこともできるメリットはある

所感

  • インタラクティブモードでコマンドの許可や権限をいちいち与えるのも面倒ではあるがインタラクティブモードのほうがセキュアなのかもしれない
  • 小さいタスクならこれで十分だが大きなタスクはまだ厳しいそう
  • VSCode + Copilot でやる AI 開発と Codex + インタラクティブモードでやる AI 開発と Codex の自動化とどれがいいかではなく結局は使い分けな気がする
  • Skills は必須ではないと思うがあったほうが作業は正確になると思う
    • すべて Codex CLI 入力時のプロンプトでまかなえる可能性もある
    • Skills にするとその辺りのやりたいことがコード化されるので見える化できるのと作業の安定性が図れそう
  • Codex CLI 以外の CLI AI エージェントでも試してみたい

最後に

Codex CLI の世界だけですべてカバーできないだろうか
結局 Codex CLI はプロンプトに対するアウトプットを返してくれるだけなのでスケジューラなど他の仕組みは自分で作るしか無いのだろうか

Codex のデスクトップアプリは「定期実行して」というとやってくれるらしいがそれと同じでなくてもいいが CLI にもほしいところ