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を準備してください。-
LLM_API_KEY: 上で用意したAPI keyを入れます。 -
PAT_USERNAME: githubのユーザーネーム -
PAT_TOKEN: githubのpat token
-
2. base containe image の作成(任意)
※デフォルトでpython-nodejsのイメージが使われているのでpython, nodejs以外に必要なモジュールがない場合はこのステップは不要です。
OpenHandsのサンドボックス環境はDockerコンテナで作成されているのでDockerfileで任意のモジュールをインストールさせることで、サンドボックス上で任意のモジュールを使うことができます。
(具体例)
- Goを用いて開発したいので、
go:1.23-bookwarmのDockerfileを用意する - 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を作成してください。
ワークフローの概要
- トリガーの設定
- 環境変数の確認
- base container imageの用意(docker build / pull image from registry)
- resolver.resolve_issueの実行
- 成功判定
- 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. 実際に動かしてみる
手順
- issueに任せたいタスクを定義する
- issueにラベル付けするor
openhands-agentをメンションする - actionsが動いていることを確認
- 自動作成されたPullRequestを確認
- マージするor追加で指示を出す
5. OpenHands resolverを使いこなすコツ
- タスクの定義
- ゴールの明確化(xxのtestをすべてパスする。)
- サンプルを与える(interfaces/usecase/hoge.goを参考にxx処理を行うinterfaces/usecase/fuga.goを実装する)。
- かなり要件は細かく書いてコーディングするだけにしておく(hogeフォームバリデーションはxx文字以内かつ記号xxが含まれているかを確認する。)
- model指定で実行させる裏技

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