🙌

GitHub ActionsでAIとソフトウェア開発する🙌

に公開

はじめに

(この記事は2025年5月時点での記事です)
この記事ではソフトウェア開発エージェントのOSSであるOpenHandsのresolverについて説明します。
はじめにOpenHandsの概要を説明し、後半にOpenhandsをGitHubと連携して開発する方法をハンズオン形式で紹介しようと思います。最後に私がやってた小技も紹介します。

こんな人に読んでほしい

  • Devinが高いので他のAIエージェントを探している
  • AIエージェントを使った開発を試してみたい

OpenHandsって何? 何が嬉しいの?

OpenHandsとは

OpenHandsはOSSのソフトウェア開発用AIエージェントです。
有名なものだとCognition社のDevinがありますが、OpenHandsはDevinのOSS版という理解でおおよそ大丈夫だと思います。

4つの実行モード

OpenHandsの特徴として4つの実行モードがあります。

1. GUIモード
GUI上で対話式に命令できる。DevinのWorkSpaceに似ている。

2. CLIモード
CLI上で対話形式で実行することができる。

3. Headlessモード
スクリプトとして実行できる。対話式でないのでWorkflowに落とし込みやすい。

4. GitHub Action
GitHubのissueを引数として実行できる。PR作成までやってくれる。

今回紹介するのは4つめの Github Action です。モジュール名はresolverなので以下resolverと呼ぶことにします。次の章で詳しく説明しようと思います。

resolverについて

resolverの利点

resolverはGitHub Actionsと連携することで「issueにfix-meラベルをつけるかissueで@openhands-agentをメンションする」だけで自動的にissueを解決してPull Requestを作成してくれます。Devinの代替を探す人にとっては一番魅力的な実行モードだと思います。

resolverの仕組み

仕組みが気になる人向けに少しだけ説明します。
resolverはGithubActionsのworkflowとして定義します。workflowのトリガーをissueへのレベル付けとして設定しているため、issueにfix-meをつけるとworkflowが実行されます。

workflow内ではresolverがissueの内容を読み込み、Dockerのサンドボックス環境を作成します。サンドボックス上でrepositoryをコピーしてコードの編集を行い差分ファイルを作成します。作業が終了したら差分ファイルを元にPullRequestを作成してくれています。

OpenHands resovler ハンズオン

実際にOpenHandsのresolverを使って、開発タスクを任せられるようにセットアップしていこうと思います。

1. 準備

  • LLMのAPI Keyを用意する
    対応しているLLMについては公式ドキュメントを参考しにしてください。

  • GitHubのPAT(personal access token)を用意する
    classicの場合はworkflowのスコープにチェックしておけば良いです。refineを使う場合はcontents, issues, pull requests,workflowsのwrite/readにチェックしておいてください。

  • secretを設定する
    OpenHandsを導入したいリポジトリのsettingsからactions内で使うためのsecretを準備します。以下のsecretを準備してください。

    1. LLM_API_KEY : 上で用意したAPI keyを入れます。
    2. PAT_USERNAME : githubのユーザーネーム
    3. PAT_TOKEN : githubのpat token

2. base containe image の作成(任意)

※デフォルトでpython-nodejsのイメージが使われているのでpython, nodejs以外に必要なモジュールがない場合はこのステップは不要です。
OpenHandsのサンドボックス環境はDockerコンテナで作成されているのでDockerfileで任意のモジュールをインストールさせることで、サンドボックス上で任意のモジュールを使うことができます。

(具体例)

  1. Goを用いて開発したいので、go:1.23-bookwarmのDockerfileを用意する
  2. yarnでパッケージ管理しながらフロントエンド開発をしたいのでnode:22-bookwarmにyarnをインストールしたDockerfileを用意する

今回はpythonとgoを使ったフルスタックアプリ開発という想定でDockerfileを作ってみます。

FROM nikolaik/python-nodejs:python3.12-nodejs22 # OpenHandsの実行のために少なくともpythonは必要。ここではデフォルトと同じですが、必要に応じて軽量なものを選ぶと良いかもです。


# # install rye
# RUN apt-get update && \
#     apt-get install -y curl && \
#     apt clean && \
#     rm -rf /var/lib/apt/lists/*

# ARG USERNAME=ryeuser
# RUN useradd ${USERNAME} --create-home
# USER ${USERNAME}

# WORKDIR /home/${USERNAME}

# ENV RYE_HOME=/home/${USERNAME}/.rye
# ENV PATH ${RYE_HOME}/shims:/home/${USERNAME}/app/.venv/bin:${PATH}

# RUN curl -sSf https://rye.astral.sh/get | RYE_INSTALL_OPTION="--yes" bash && \
# rye config --set-bool behavior.global-python=true
# temp comment out

# # install go
# RUN apt-get update && apt-get install -y golang

# # # set go path
# ENV GOPATH=/go
# ENV PATH=$PATH:$GOPATH/bin

# # # install go dependencies
# RUN go mod init github.com/openhands-ai/openhands-resolver

作成したイメージの適用方法

Dockerfileを元に base container image を用意するにはGithubActions上でbuildするか、container registryからイメージをPullする必要があります。openhands-resolver.ymlのサンプルコードで詳しく説明しようと思います。

3. openhands-resolver.ymlの作成

Github Actionsのワークフローを追加するためにymlを作成します。
プロジェクトルートに.github/workflows/openhands-resolver.ymlを作成してください。

ワークフローの概要

  1. トリガーの設定
  2. 環境変数の確認
  3. base container imageの用意(docker build / pull image from registry)
  4. resolver.resolve_issueの実行
  5. 成功判定
  6. Pull Requestの作成

以下のコードをコピペして好みでカスタマイズしてください。

# OpenHandsの検証用のworkflowです。検証後に削除します。
name: Auto-Fix Tagged Issue with OpenHands

on:
  workflow_call:
    secrets:
      LLM_MODEL:
        required: false
      LLM_API_KEY:
        required: true
      PAT_TOKEN:
        required: false
      PAT_USERNAME:
        required: false

  issues:
    types: [labeled]
  pull_request:
    types: [labeled]
  issue_comment:
    types: [created]
  pull_request_review_comment:
    types: [created]
  pull_request_review:
    types: [submitted]

permissions:
  contents: write
  pull-requests: write
  issues: write

jobs:
  auto-fix:
    if: |
      github.event_name == 'workflow_call' ||
      github.event.label.name == 'fix-me-gpt4o' ||
      github.event.label.name == 'fix-me-claude3.5sonnet' ||
      github.event.label.name == 'fix-me-claude3.7sonnet' ||
      github.event.label.name == 'fix-me-claude-sonnet-4' ||
      (
        ((github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment') &&
        contains(github.event.comment.body,'@openhands-agent') &&
        (github.event.comment.author_association == 'OWNER' || github.event.comment.author_association == 'COLLABORATOR' || github.event.comment.author_association == 'MEMBER')
        ) ||
        (github.event_name == 'pull_request_review' &&
        contains(github.event.review.body,'@openhands-agent') &&
        (github.event.review.author_association == 'OWNER' || github.event.review.author_association == 'COLLABORATOR' || github.event.review.author_association == 'MEMBER')
        )
      )
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.12"

      - name: Get latest versions and create requirements.txt
        run: |
          python -m pip index versions openhands-ai > openhands_versions.txt
          OPENHANDS_VERSION=$(head -n 1 openhands_versions.txt | awk '{print $2}' | tr -d '()')
          # Create a new requirements.txt locally within the workflow, ensuring no reference to the repo's file
          echo "openhands-ai==${OPENHANDS_VERSION}" > /tmp/requirements.txt
          cat /tmp/requirements.txt
      - name: Cache pip dependencies
        if: |
          !(
            github.event.label.name == 'fix-me-experimental' ||
            (
              (github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment') &&
              contains(github.event.comment.body, '@openhands-agent-exp')
            ) ||
            (
              github.event_name == 'pull_request_review' &&
              contains(github.event.review.body, '@openhands-agent-exp')
            )
          )
        uses: actions/cache@v4
        with:
          path: ${{ env.pythonLocation }}/lib/python3.12/site-packages/*
          key: ${{ runner.os }}-pip-openhands-resolver-${{ hashFiles('/tmp/requirements.txt') }}
          restore-keys: |
            ${{ runner.os }}-pip-openhands-resolver-${{ hashFiles('/tmp/requirements.txt') }}
      - name: Check required environment variables
        env:
          LLM_API_VERSION: ""
          PAT_TOKEN: ${{ secrets.OPENHANDS_PAT_TOKEN }} # githubのPAT
          PAT_USERNAME: ${{ secrets.OPENHANDS_PAT_USERNAME }} # githubのusername
          GITHUB_TOKEN: ${{ github.token }}
        run: |
          # Check optional variables and warn about fallbacks
          if [ -z "$LLM_BASE_URL" ]; then
            echo "Warning: LLM_BASE_URL is not set, will use default API endpoint"
          fi
          if [ -z "$PAT_TOKEN" ]; then
            echo "Warning: PAT_TOKEN is not set, falling back to GITHUB_TOKEN"
          fi
          if [ -z "$PAT_USERNAME" ]; then
            echo "Warning: PAT_USERNAME is not set, will use openhands-agent"
          fi
      - name: Set environment variables
        run: |
          # Handle pull request events first
          if [ -n "${{ github.event.pull_request.number }}" ]; then
            echo "ISSUE_NUMBER=${{ github.event.pull_request.number }}" >> $GITHUB_ENV
            echo "ISSUE_TYPE=pr" >> $GITHUB_ENV
          # Handle pull request review events
          elif [ -n "${{ github.event.review.body }}" ]; then
            echo "ISSUE_NUMBER=${{ github.event.pull_request.number }}" >> $GITHUB_ENV
            echo "ISSUE_TYPE=pr" >> $GITHUB_ENV
          # Handle issue comment events that reference a PR
          elif [ -n "${{ github.event.issue.pull_request }}" ]; then
            echo "ISSUE_NUMBER=${{ github.event.issue.number }}" >> $GITHUB_ENV
            echo "ISSUE_TYPE=pr" >> $GITHUB_ENV
          # Handle regular issue events
          else
            echo "ISSUE_NUMBER=${{ github.event.issue.number }}" >> $GITHUB_ENV
            echo "ISSUE_TYPE=issue" >> $GITHUB_ENV
          fi
          if [ -n "${{ github.event.review.body }}" ]; then
            echo "COMMENT_ID=${{ github.event.review.id || 'None' }}" >> $GITHUB_ENV
          else
            echo "COMMENT_ID=${{ github.event.comment.id || 'None' }}" >> $GITHUB_ENV
          fi
          echo "MAX_ITERATIONS=50" >> $GITHUB_ENV
          echo "SANDBOX_ENV_GITHUB_TOKEN=${{ secrets.OPENHANDS_PAT_TOKEN || github.token }}" >> $GITHUB_ENV
          # Set branch variables
          echo "TARGET_BRANCH=${{ inputs.target_branch || 'main' }}" >> $GITHUB_ENV

          # Set LLM Model
          if [ "${{ github.event.label.name }}" == "fix-me-gpt4o" ]; then
            echo "LLM_MODEL=gpt-4o" >> $GITHUB_ENV
          elif [ "${{ github.event.label.name }}" == "fix-me-claude3.5sonnet" ]; then
            echo "LLM_MODEL=claude-3-5-sonnet-20241022" >> $GITHUB_ENV
          elif [ "${{ github.event.label.name }}" == "fix-me-claude3.7sonnet" ]; then
            echo "LLM_MODEL=claude-3-7-sonnet-20250219" >> $GITHUB_ENV
          elif [ "${{ github.event.label.name }}" == "fix-me-claude-sonnet-4" ]; then
            echo "LLM_MODEL=claude-sonnet-4-20250514" >> $GITHUB_ENV
          else
            # @openhands-agentメンションの場合のため
            echo "use default model"
            echo "LLM_MODEL=${{ secrets.OPENHANDS_LLM_MODEL }}" >> $GITHUB_ENV
          fi

          # Set LLM API key
          if [ "${{ github.event.label.name }}" == "fix-me-gpt4o" ]; then
            echo "LLM_API_KEY=${{ secrets.OPENHANDS_OPENAI_API_KEY }}" >> $GITHUB_ENV
          elif [ "${{ github.event.label.name }}" == "fix-me-claude3.5sonnet" ]; then
            echo "LLM_API_KEY=${{ secrets.OPENHANDS_ANTHROPIC_API_KEY }}" >> $GITHUB_ENV
          elif [ "${{ github.event.label.name }}" == "fix-me-claude3.7sonnet" ]; then
            echo "LLM_API_KEY=${{ secrets.OPENHANDS_ANTHROPIC_API_KEY }}" >> $GITHUB_ENV
          elif [ "${{ github.event.label.name }}" == "fix-me-claude-sonnet-4" ]; then
            echo "LLM_API_KEY=${{ secrets.OPENHANDS_ANTHROPIC_API_KEY }}" >> $GITHUB_ENV
          else
            # @openhands-agentメンションの場合のため
            echo "use default key for default model"
            echo "LLM_API_KEY=${{ secrets.OPENHANDS_LLM_API_KEY }}" >> $GITHUB_ENV
          fi
      - name: Comment on issue with start message
        uses: actions/github-script@v7
        with:
          github-token: ${{ secrets.OPENHANDS_PAT_TOKEN || github.token }}
          script: |
            const issueType = process.env.ISSUE_TYPE;
            github.rest.issues.createComment({
              issue_number: ${{ env.ISSUE_NUMBER }},
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: `[OpenHands](https://github.com/All-Hands-AI/OpenHands) started fixing the ${issueType}! You can monitor the progress [here](https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}).`
            });
      - name: Install OpenHands
        id: install_openhands
        uses: actions/github-script@v7
        env:
          COMMENT_BODY: ${{ github.event.comment.body || '' }}
          REVIEW_BODY: ${{ github.event.review.body || '' }}
          LABEL_NAME: ${{ github.event.label.name || '' }}
          EVENT_NAME: ${{ github.event_name }}
        with:
          script: |
            const commentBody = process.env.COMMENT_BODY.trim();
            const reviewBody = process.env.REVIEW_BODY.trim();
            const labelName = process.env.LABEL_NAME.trim();
            const eventName = process.env.EVENT_NAME.trim();
            // Check conditions
            const isExperimentalLabel = labelName === "fix-me-experimental";
            const isIssueCommentExperimental =
              (eventName === "issue_comment" || eventName === "pull_request_review_comment") &&
              commentBody.includes("@openhands-agent-exp");
            const isReviewCommentExperimental =
              eventName === "pull_request_review" && reviewBody.includes("@openhands-agent-exp");
            // Set output variable
            core.setOutput('isExperimental', isExperimentalLabel || isIssueCommentExperimental || isReviewCommentExperimental);
            // Perform package installation
            console.log("Installing experimental OpenHands...");
            await exec.exec("python -m pip install --upgrade pip");
            await exec.exec("pip install git+https://github.com/all-hands-ai/openhands.git");
      - name: Attempt to resolve issue
        env:
          GITHUB_TOKEN: ${{ secrets.OPENHANDS_PAT_TOKEN || github.token }}
          GITHUB_USERNAME: ${{ secrets.OPENHANDS_PAT_USERNAME || 'openhands-agent' }}
          GIT_USERNAME: ${{ secrets.OPENHANDS_PAT_USERNAME || 'openhands-agent' }}
          LLM_API_VERSION: ""
          PYTHONPATH: ""
          BASE_CONTAINER_IMAGE: sandbox:latest
        run: |
          echo "Resolving issue..."
          cd /tmp && python -m openhands.resolver.resolve_issue \
            --username ${{ env.GITHUB_USERNAME }} \
            --selected-repo ${{ github.repository }} \
            --issue-type ${{ env.ISSUE_TYPE }} \
            --issue-number ${{ env.ISSUE_NUMBER }} \
            --token ${{ env.GITHUB_TOKEN }} \
            --llm-model ${{ env.LLM_MODEL }} \
            --llm-api-key ${{ env.LLM_API_KEY }}
            # --base-container-image sandbox:latest
      - name: Check resolution result
        id: check_result
        run: |
          if cd /tmp && grep -q '"success":true' output/output.jsonl; then
            echo "RESOLUTION_SUCCESS=true" >> $GITHUB_OUTPUT
          else
            echo "RESOLUTION_SUCCESS=false" >> $GITHUB_OUTPUT
          fi
      - name: Upload output.jsonl as artifact
        uses: actions/upload-artifact@v4
        if: always() # Upload even if the previous steps fail
        with:
          name: resolver-output
          path: /tmp/output/output.jsonl
          retention-days: 30 # Keep the artifact for 30 days

      - name: Create draft PR or push branch
        if: always() # Create PR or branch even if the previous steps fail
        env:
          GITHUB_TOKEN: ${{ secrets.OPENHANDS_PAT_TOKEN || github.token }}
          GITHUB_USERNAME: ${{ secrets.OPENHANDS_PAT_USERNAME || 'openhands-agent' }}
          GIT_USERNAME: ${{ secrets.OPENHANDS_PAT_USERNAME || 'openhands-agent' }}
          LLM_API_VERSION: ""
          PYTHONPATH: ""

        run: |
          if [ "${{ steps.check_result.outputs.RESOLUTION_SUCCESS }}" == "true" ]; then
            cd /tmp && python -m openhands.resolver.send_pull_request \
              --issue-number ${{ env.ISSUE_NUMBER }} \
              --target-branch ${{ env.TARGET_BRANCH }} \
              --pr-type draft \
              --reviewer ${{ github.actor }} \
              --token ${{ env.GITHUB_TOKEN }} | tee pr_result.txt && \
              grep "draft created" pr_result.txt | sed 's/.*\///g' > pr_number.txt
          else
            cd /tmp && python -m openhands.resolver.send_pull_request \
              --issue-number ${{ env.ISSUE_NUMBER }} \
              --pr-type branch \
              --send-on-failure | tee branch_result.txt && \
              grep "branch created" branch_result.txt | sed 's/.*\///g; s/.expand=1//g' > branch_name.txt
          fi
      # Step leaves comment for when agent is invoked on PR
      - name: Analyze Push Logs (Updated PR or No Changes) # Skip comment if PR update was successful OR leave comment if the agent made no code changes
        uses: actions/github-script@v7
        if: always()
        env:
          AGENT_RESPONDED: ${{ env.AGENT_RESPONDED || 'false' }}
        with:
          github-token: ${{ secrets.OPENHANDS_PAT_TOKEN || github.token }}
          script: |
            const fs = require('fs');
            const issueNumber = ${{ env.ISSUE_NUMBER }};
            let logContent = '';
            try {
              logContent = fs.readFileSync('/tmp/pr_result.txt', 'utf8').trim();
            } catch (error) {
              console.error('Error reading pr_result.txt file:', error);
            }
            const noChangesMessage = `No changes to commit for issue #${issueNumber}. Skipping commit.`;
            // Check logs from send_pull_request.py (pushes code to GitHub)
            if (logContent.includes("Updated pull request")) {
              console.log("Updated pull request found. Skipping comment.");
              process.env.AGENT_RESPONDED = 'true';
            } else if (logContent.includes(noChangesMessage)) {
              github.rest.issues.createComment({
                issue_number: issueNumber,
                owner: context.repo.owner,
                repo: context.repo.repo,
                body: `The workflow to fix this issue encountered an error. Openhands failed to create any code changes.`
              });
              process.env.AGENT_RESPONDED = 'true';
            }
      # Step leaves comment for when agent is invoked on issue
      - name: Comment on issue # Comment link to either PR or branch created by agent
        uses: actions/github-script@v7
        if: always() # Comment on issue even if the previous steps fail
        env:
          AGENT_RESPONDED: ${{ env.AGENT_RESPONDED || 'false' }}
        with:
          github-token: ${{ secrets.OPENHANDS_PAT_TOKEN || github.token }}
          script: |
            const fs = require('fs');
            const path = require('path');
            const issueNumber = ${{ env.ISSUE_NUMBER }};
            const success = ${{ steps.check_result.outputs.RESOLUTION_SUCCESS }};
            let prNumber = '';
            let branchName = '';
            let resultExplanation = '';
            try {
              if (success) {
                prNumber = fs.readFileSync('/tmp/pr_number.txt', 'utf8').trim();
              } else {
                branchName = fs.readFileSync('/tmp/branch_name.txt', 'utf8').trim();
              }
            } catch (error) {
              console.error('Error reading file:', error);
            }
            try {
              if (!success){
                // Read result_explanation from JSON file for failed resolution
                const outputFilePath = path.resolve('/tmp/output/output.jsonl');
                if (fs.existsSync(outputFilePath)) {
                  const outputContent = fs.readFileSync(outputFilePath, 'utf8');
                  const jsonLines = outputContent.split('\n').filter(line => line.trim() !== '');
                  if (jsonLines.length > 0) {
                    // First entry in JSON lines has the key 'result_explanation'
                    const firstEntry = JSON.parse(jsonLines[0]);
                    resultExplanation = firstEntry.result_explanation || '';
                  }
                }
              }
            } catch (error){
              console.error('Error reading file:', error);
            }
            // Check "success" log from resolver output
            if (success && prNumber) {
              github.rest.issues.createComment({
                issue_number: issueNumber,
                owner: context.repo.owner,
                repo: context.repo.repo,
                body: `A potential fix has been generated and a draft PR #${prNumber} has been created. Please review the changes.`
              });
              process.env.AGENT_RESPONDED = 'true';
            } else if (!success && branchName) {
              let commentBody = `An attempt was made to automatically fix this issue, but it was unsuccessful. A branch named '${branchName}' has been created with the attempted changes. You can view the branch [here](https://github.com/${context.repo.owner}/${context.repo.repo}/tree/${branchName}). Manual intervention may be required.`;
              if (resultExplanation) {
                commentBody += `\n\nAdditional details about the failure:\n${resultExplanation}`;
              }
              github.rest.issues.createComment({
                issue_number: issueNumber,
                owner: context.repo.owner,
                repo: context.repo.repo,
                body: commentBody
              });
              process.env.AGENT_RESPONDED = 'true';
            }
      # Leave error comment when both PR/Issue comment handling fail
      - name: Fallback Error Comment
        uses: actions/github-script@v7
        if: ${{ env.AGENT_RESPONDED == 'false' }} # Only run if no conditions were met in previous steps
        with:
          github-token: ${{ secrets.OPENHANDS_PAT_TOKEN || github.token }}
          script: |
            const issueNumber = ${{ env.ISSUE_NUMBER }};
            github.rest.issues.createComment({
              issue_number: issueNumber,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: `The workflow to fix this issue encountered an error. Please check the [workflow logs](https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}) for more information.`
            });

4. 実際に動かしてみる

手順

  1. issueに任せたいタスクを定義する
  2. issueにラベル付けするoropenhands-agentをメンションする
  3. actionsが動いていることを確認
  4. 自動作成されたPullRequestを確認
  5. マージするor追加で指示を出す

5. OpenHands resolverを使いこなすコツ

  1. タスクの定義
    1. ゴールの明確化(xxのtestをすべてパスする。)
    2. サンプルを与える(interfaces/usecase/hoge.goを参考にxx処理を行うinterfaces/usecase/fuga.goを実装する)。
    3. かなり要件は細かく書いてコーディングするだけにしておく(hogeフォームバリデーションはxx文字以内かつ記号xxが含まれているかを確認する。)
  2. model指定で実行させる裏技

    github issueのlabelをモデル毎に用意しておいて、labelをactions側で条件分岐するようにしておきます。そうするとタスクごとにモデルを使い分けたりできるし、新たなモデルが公開されたときに差し替えが簡単かつ性能比較ができます。

Discussion