2
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

GitHub IssueをAIが無料で自動解決できるOpenHands Resolver

Posted at

AI Codingツールの分類

最近、生成AIを利用したコーディングツールは色々とあります。私は大別すると2種類あると思っています。

  • VSCodeベース
    • Plugin
      • Github Copilot
      • Cline
      • RooCode
    • 非Plugin
      • Windsurf
      • Cursor
  • 非VSCodeベース
    • 非OSS
      • Devin
      • Manus
    • OSS
      • OpenHands
      • Plandex
      • Codex CLI

 初期の生成AIのコーディングにおいては、GithubCopilotがVSCodeのプラグインとして提供され、とても賢いコードの補完といった感じでした。
 しかし、2025年ぐらいからVSCodeにプラグインを追加する形のClineやRooCodeにより、コーディング自体を生成AIが自動で行うものが増えてきました。そのような中で、VSCode自体を改造するWindsurf/Cursorというタイプも出てきました。
 その後、AI Coding自体をSaaS化してしまい、自然言語で仕様を指示すると、コード自体を生成するDevin/Manusが話題になりました。
 そして、それらのSaaS型のAI Coding自体をOSSとして模倣する動きもあり、その1つがOpenHandsである。と認識しています(OpenHandsはもともとOpenDevinという名前でした)。OpenHandsでも割と複雑なコードを扱えるのですが、それ以上に複雑なコードが扱えるプロダクトとして出てきたのがPlandexです。

先述した通りAI CodingはVSCodeのプラグインとして提供されるものが多かったのですが、最近はCLIベースのツールも増えており、Claude CodeやCodex CLIなども出てきています。

OpenHands Resolverとは

 OpenHands Resolverとは、GithubのIssueにIssueを作るとOpenHands(AI)が自動でその内容を読み取り、プルリクエストを作成してくれる機能です。

image.png

私がコーディングしているように見えるのはGithubのPATトークンを用いて動作する仕様のためです。
 実際に作られたPull Reqeustはこちらです。

image.png

このOpenHands Issue ResolverはGoogle Geminiの無料枠で完全無料で動作させることができます。
パブリックなリポジトリだけでなく、プライベートなリポジトリでも動作可能です。

OpenHands Resolverの設定方法

OpenHands Resolverの設定は3ステップです。

  1. Github Actionsにワークフローを追加
  2. Gemini APIトークンの設定
  3. Github PATトークンの設定

Github Actionsの設定

自分のリポジトリーに.github/workflows/openhands-resolver.ymlを作成します。

.github/workflows/openhands-resolver.yml
name: Auto-Fix Tagged Issue with OpenHands

on:
  workflow_call:
    inputs:
      max_iterations:
        required: false
        type: number
        default: 50
      macro:
        required: false
        type: string
        default: "@openhands-agent"
      target_branch:
        required: false
        type: string
        default: "main"
        description: "Target branch to pull and create PR against"
      pr_type:
        required: false
        type: string
        default: "draft"
        description: "The PR type that is going to be created (draft, ready)"
      LLM_MODEL:
        required: false
        type: string
        default: "anthropic/claude-sonnet-4-20250514"
      LLM_API_VERSION:
        required: false
        type: string
        default: ""
      base_container_image:
        required: false
        type: string
        default: ""
        description: "Custom sandbox env"
      runner:
        required: false
        type: string
        default: "ubuntu-latest"
    secrets:
      LLM_MODEL:
        required: false
      LLM_API_KEY:
        required: true
      LLM_BASE_URL:
        required: false
      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' ||
      github.event.label.name == 'fix-me-experimental' ||
      (
        ((github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment') &&
        contains(github.event.comment.body, inputs.macro || '@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, inputs.macro || '@openhands-agent') &&
        (github.event.review.author_association == 'OWNER' || github.event.review.author_association == 'COLLABORATOR' || github.event.review.author_association == 'MEMBER')
        )
      )
    runs-on: "${{ inputs.runner || 'ubuntu-latest' }}"
    env:
      LLM_NUM_RETRIES: 1000
      LLM_RETRY_MIN_WAIT: 60
      LLM_RETRY_MAX_WAIT: 3000
      LLM_TIMEOUT: 300
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - name: Upgrade pip
        run: |
          python -m pip install --upgrade pip

      - 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_MODEL: ${{ secrets.LLM_MODEL || inputs.LLM_MODEL }}
          LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
          LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
          LLM_API_VERSION: ${{ inputs.LLM_API_VERSION }}
          PAT_TOKEN: ${{ secrets.PAT_TOKEN }}
          PAT_USERNAME: ${{ secrets.PAT_USERNAME }}
          GITHUB_TOKEN: ${{ github.token }}
        run: |
          required_vars=("LLM_API_KEY")
          for var in "${required_vars[@]}"; do
            if [ -z "${!var}" ]; then
              echo "Error: Required environment variable $var is not set."
              exit 1
            fi
          done

          # 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
        env:
          REVIEW_BODY: ${{ github.event.review.body || '' }}
        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 "$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 "$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=${{ inputs.max_iterations || 50 }}" >> $GITHUB_ENV
          echo "SANDBOX_ENV_GITHUB_TOKEN=${{ secrets.PAT_TOKEN || github.token }}" >> $GITHUB_ENV
          echo "SANDBOX_BASE_CONTAINER_IMAGE=${{ inputs.base_container_image }}" >> $GITHUB_ENV

          # Set branch variables
          echo "TARGET_BRANCH=${{ inputs.target_branch || 'main' }}" >> $GITHUB_ENV

      - name: Comment on issue with start message
        uses: actions/github-script@v7
        with:
          github-token: ${{ secrets.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
            if (isExperimentalLabel || isIssueCommentExperimental || isReviewCommentExperimental) {
              console.log("Installing experimental OpenHands...");

              await exec.exec("pip install git+https://github.com/all-hands-ai/openhands.git");
            } else {
              console.log("Installing from requirements.txt...");

              await exec.exec("pip install -r /tmp/requirements.txt");
            }

      - name: Attempt to resolve issue
        env:
          GITHUB_TOKEN: ${{ secrets.PAT_TOKEN || github.token }}
          GITHUB_USERNAME: ${{ secrets.PAT_USERNAME || 'openhands-agent' }}
          GIT_USERNAME: ${{ secrets.PAT_USERNAME || 'openhands-agent' }}
          LLM_MODEL: ${{ secrets.LLM_MODEL || inputs.LLM_MODEL }}
          LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
          LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
          LLM_API_VERSION: ${{ inputs.LLM_API_VERSION }}
          PYTHONPATH: ""
        run: |
          cd /tmp && python -m openhands.resolver.resolve_issue \
            --selected-repo ${{ github.repository }} \
            --issue-number ${{ env.ISSUE_NUMBER }} \
            --issue-type ${{ env.ISSUE_TYPE }} \
            --max-iterations ${{ env.MAX_ITERATIONS }} \
            --comment-id ${{ env.COMMENT_ID }} \
            --is-experimental ${{ steps.install_openhands.outputs.isExperimental }}

      - 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.PAT_TOKEN || github.token }}
          GITHUB_USERNAME: ${{ secrets.PAT_USERNAME || 'openhands-agent' }}
          GIT_USERNAME: ${{ secrets.PAT_USERNAME || 'openhands-agent' }}
          LLM_MODEL: ${{ secrets.LLM_MODEL || inputs.LLM_MODEL }}
          LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
          LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
          LLM_API_VERSION: ${{ inputs.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 ${{ inputs.pr_type || 'draft' }} \
              --reviewer ${{ github.actor }} | tee pr_result.txt && \
              grep "PR 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' }}
          ISSUE_NUMBER: ${{ env.ISSUE_NUMBER }}
        with:
          github-token: ${{ secrets.PAT_TOKEN || github.token }}
          script: |
            const fs = require('fs');
            const issueNumber = process.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' }}
          ISSUE_NUMBER: ${{ env.ISSUE_NUMBER }}
          RESOLUTION_SUCCESS: ${{ steps.check_result.outputs.RESOLUTION_SUCCESS }}
        with:
          github-token: ${{ secrets.PAT_TOKEN || github.token }}
          script: |
            const fs = require('fs');
            const path = require('path');
            const issueNumber = process.env.ISSUE_NUMBER;
            const success = process.env.RESOLUTION_SUCCESS === 'true';

            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
        env:
          ISSUE_NUMBER: ${{ env.ISSUE_NUMBER }}
        with:
          github-token: ${{ secrets.PAT_TOKEN || github.token }}
          script: |
            const issueNumber = process.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.`
            });

ほぼ内容自体は公式のworkflowと同じものです。

ただし、Geminiの無料枠で動かすために工夫をしています(後述)。

Gemini API トークンの生成・設定

OpenHands Issue Resolverを動かすためには、4つのSecretsを設定する必要があります。

image.png

LLM_API_KEYとして、GoogleのGeminiのAPIキーを設定します。
これは以下のURLで発行します。Geminiは無料枠があるので、軽く触るにはこれで十分です。

次に、LLM_MODELにモデル名を指定します。オプションで指定できるモデルはGoogleのドキュメントを参考にします。

設定の仕方としては、gemini-2.0-flashのような値を指定します。

Github PAT トークンの生成・設定

GithubのPATトークンの設定が若干ややこしいです。
以下のURLでGithubのPersonal Access Tokenを生成します。

一番ややこしいのがここです。「Only select repositories」を指定する必要があります。

image.png

Publicなリポジトリだからと言って、「Public repositories」を設定すると動作しません。「All Repositories」でも多分動きますが、セキュリティ的にはリポジトリを絞ったほうがいいと思います。
そのあと、「Repository permissions」を設定します。OpenHandsに何をやってもらいたいか。次第ではありますが、結構選択する必要があります。
私は以下のあたりを設定しています。

設定項目 権限
Actions Read-Write
Commit statuses Read-Write
Contents Read-Write
Discussions Read-Write
Environments Read
Issues Read-Write
Pull requests Read-Write
Secrets Read
Variables Read
Webhooks Read-Write
Workflows Read-Write

気持ちとしては、「Contents」「Actions」「Issues」「Pull Request」「Workflows」あたりの権限を与えれば最小限何とかなると思っていますが、そこまで完全な検証はしていないです。
ここで生成したPATトークンをSecretsのPAT_TOKENに設定します。また、PAT_USERNAMEには自分のGithubでのユーザー名を指定します。

OpenHandsにGithub上でIssueを解決させる

使い方は簡単です。GithubでIssueを作成します。そして、fix-meというラベルを付けると自動でOpenHandsがGithub Actions上でコーディングをし始めます。

image.png

実際の動作ログ

OpenHandsにPRを修正させる

OpenHandsのResolverにはPRのコメントをトリガーに修正をする機能があります。
一般的なレビューと同じようにソースコードにコメントを残します。その時に、@openhand-agentとメンションを付けます。

image.png

「Start a review」でも「Add single comment」どちらでも構いません。
そうすることで、OpenHandsがコードの修正を行います。

image.png

OpenHands Resolverの設定におけるポイント

ただし、Geminiの無料枠で動かすために工夫をしています(後述)。

と書きました。OpenHands公式のworkflowをコピペで済むのですが、以下のようなコードを追加しています。

    env:
      LLM_NUM_RETRIES: 1000
      LLM_RETRY_MIN_WAIT: 60
      LLM_RETRY_MAX_WAIT: 3000
      LLM_TIMEOUT: 300

 Google Geminiの無料枠は上限があります。そのため、すぐに上限が来てしまいます。その際、リトライするのですが、そのリトライの上限が低いため、自分で上限をあげてやる必要があります。そうすることで、Geminiの無料枠でも利用することが可能になります。そのため、公式のworkflowが変更され、自分のリポジトリに持ってくる際は、これを書く必要があります。

OpenHandsへのコントリビュート

 先日、私がOpenHandsのコントリビューターとして名前が掲載されました。

何をしたか。というと、前節の 「リトライの上限を変更する機能」を私が実装しました。
OpenHandsのドキュメントには、LLM_NUM_RETRIESなど、リトライに関する環境変数を設定する機能の記載があります。

image.png

しかし、いくら設定してもResolverでは動作しないと困っていました。結果、ソースコードを読むと、設定読み込みルーチンがOpenHands本体とResolverで共通化されておらず、「リトライの上限が変更できない」という状態でした。

というわけでパワー(やや強引)で設定できるようにPRを作り、マージされたのが先ほどの話でした。

OSSへの貢献というのは初めてなのですが、意外になんとかなりました。
Devinが割とうらやましいなーと思って、色々とOSSを触っていましたが、その折に見つけたのがOpenHandsでした。それで、Resolverの記事を見つけ、自分で設定したのですが、一部動かない。なんか半分ぐらい動かない?と思って追ったところ、リトライ上限が反映されないという挙動でした。
 そんなわけでResolverは、最初あまり使えないなーという感想だったのですが、リトライの上限が反映されるようになって、かなり安定的に動くようになりました。RooCodeはパソコンの前に座っている必要がありますが、これはIssue投げるだけで、GithubActions上で勝手に解決してくれるので、とりあえずお願い!といってIssueだけ作って、運試しで投げておく。しかも無料で。ということで、軽微な修正ならこれで十分だったりします。加えてCIなど自動テストを整えるとかなり強くなってきます。
 なので、比較的お手軽に無料でセットアップ出来るのでOpenHandsのResolverを試してみるのはおススメです。

Github Sponsor

2
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?