GitLab MRにコミットを積んだ直後のmerge APIが422で落ちる理由

AIレビューパイプラインを組んでいたときのことだ。レビュー結果の修正コミットをpushした直後にMRをマージする処理を書いた。ローカルでは動くのに、CIに載せると 422 Branch cannot be merged で落ちる。3回修正して3回失敗した記録と、最終的にたどり着いた原因の話をしよう。

構成と症状

ブログの記事レビューを自動化している。CIのllm-reviewジョブがMRの記事をレビューし、AIが提案した修正をコミットとしてpushし、そのままMRをマージする流れだ。

llm-review ジョブ:
  1. 記事をレビュー(3ペルソナ並列)
  2. 修正提案があればコミットをpush(bot-reviewコミット)
  3. MRをapprove
  4. merge_mr() を呼ぶ  ← ここで422

python-gitlab 4.x の mr.merge() を使っている。bot-reviewコミットなしでマージする場合は問題ないが、コミットをpushした後にマージしようとすると落ちる。

gitlab.exceptions.GitlabMRClosedError: 422: Branch cannot be merged

過去の運用で11本のbot MRを作成したが、自動マージが成功したのは3本だけだった。残りは全部手動マージだった。

最初の3回の誤診

試行1: リトライ+警告(MR #93)

「一時的なエラーだろう」と判断し、merge_mr() に15秒間隔のリトライを追加した。3回リトライしても失敗する場合はWARNINGを出して続行する実装にした。

結果: マージされないまま放置されるだけだった。WARNINGで済ませた時点で、MRが未マージ、つまり記事が公開されないという事実を見落としていた。

試行2: merge_when_pipeline_succeeds(MR #94)

bot-reviewコミットが新しいパイプラインをトリガーしているから、そのパイプラインが終わるまでマージできないと考えた。GitLab APIの merge_when_pipeline_succeeds=True を使えば、パイプライン完了後に自動マージされるはずだ、と。

結果: 405 Method Not Allowed。pushした直後はパイプラインがまだ作成されていない。HEADパイプラインが存在しない状態で merge_when_pipeline_succeeds を呼ぶと405が返る。

試行3: パイプライン作成待機(MR #96)

405の原因は「パイプラインがまだない」からだ。パイプラインが作成されるまでポーリングしてから merge_when_pipeline_succeeds を呼べばいい。

def wait_for_pipeline_creation(self, project_id, branch, commit_sha, *, timeout=60, interval=5):
    project = self._get_project(project_id)
    deadline = time.time() + timeout
    while time.time() < deadline:
        pipelines = project.pipelines.list(ref=branch, sha=commit_sha, get_all=False)
        if pipelines:
            return
        time.sleep(interval)
    raise TimeoutError(...)

この時点で、自分がパイプラインのタイミング問題だと思い込んでいることに気づいていなかった。

実際のエラーを読み直す

3回失敗した後、過去のジョブログを全部引っ張り出した。

# MR #89 (失敗)
gitlab.exceptions.GitlabMRClosedError: 422: Branch cannot be merged

# MR #92 (失敗)
gitlab.exceptions.GitlabMRClosedError: 422: Branch cannot be merged

# MR #95 (失敗 — MR #94の修正適用後)
gitlab.exceptions.GitlabMRClosedError: 405: 405 Method Not Allowed

MR #89 と #92 は 422 だ。405 ではない。パイプラインの問題なら 405409 が返るが、422 は「このMRはマージできない状態です」という意味になる。

プロジェクト設定を確認した。

proj = gl.projects.get("tkdev/blog-articles")
print(proj.only_allow_merge_if_pipeline_succeeds)  # False

パイプラインの成否はマージの前提条件になっていなかった。3回の修正は全部、存在しない問題を直そうとしていた。

本当の原因: merge_status の再計算

GitLabはMRのソースブランチにコミットがpushされると、内部で merge_statuschecking にリセットする。targetブランチとの差分を再計算し、マージ可能かどうかを判定し直す処理だ。

この再計算が終わるまでの数秒間、merge_statuscan_be_merged ではない。その間に mr.merge() を呼ぶと 422 Branch cannot be merged が返る。

補足: GitLab の merge_status が取りうる主な値は uncheckedcheckingcan_be_mergedcannot_be_merged の4種類。コミットpush直後は unchecked または checking になり、再計算完了後に can_be_mergedcannot_be_merged に遷移する。mr.merge() が422を返すのは can_be_merged 以外のすべての状態が対象となる。

過去に成功した3本のMR(#71, #82, #85)は、元のコードにあった wait_for_mr_pipeline() のポーリング中(10秒間隔)にたまたま再計算が終わっていただけだった。

補足: これは「偶発的な成功」の典型例で、再現性のないタイミング依存バグの特徴でもある。成功率が低い(11本中3本)のは、GitLabサーバーの負荷やネットワーク遅延によって再計算時間が変動するためと考えられる。

修正

2点。

1. bot-reviewコミットに [skip ci] を付ける

commit_message = f"chore(review): apply ai suggestions [bot-review] [skip ci]"

bot-reviewコミットの中身はAIレビューが承認済みのテキスト修正だけだ。このコミットで新たにパイプラインを走らせる意味はない。[skip ci] でパイプライン生成自体を止める。

only_allow_merge_if_pipeline_succeedsFalse なので、HEADコミットにパイプラインがなくてもマージはブロックされない。

補足: only_allow_merge_if_pipeline_succeeds は GitLab プロジェクトの Settings → General → Merge requests にある「Pipelines must succeed」オプションに対応する。True の場合、HEADコミットに成功したパイプラインが存在しないとマージAPIは409を返す。

2. merge_status をポーリングしてからマージ

def wait_for_mergeable(self, project_id, mr_iid, *, timeout=30, interval=3):

(実測では再計算は5秒以内に終わるが、余裕を見て30秒に設定した。)

注: 上記コードブロックは記事中で途中省略されている。完全な実装は関数シグネチャの直後に `deadline = time.time() + timeout` から始まるポーリングループが続く形になる(後述の呼び出し側コードと対応)。

    deadline = time.time() + timeout
    while time.time() < deadline:
        mr = self._get_mr(project_id, mr_iid)
        status = mr.merge_status
        if status == "can_be_merged":
            return
        print(f"MR merge_status: {status}, waiting...")
        time.sleep(interval)
    raise TimeoutError(
        f"MR !{mr_iid} still not mergeable after {timeout}s (status: {status})"
    )

呼び出し側はシンプルになった。

if bot_commit_sha:
    gitlab_client.wait_for_mergeable(project_id, mr_iid)
gitlab_client.merge_mr(project_id, mr_iid)

set_auto_mergewait_for_pipeline_creation も不要になった。コードは27行減った。

3回失敗して学んだこと

最初のエラーログに 422 Branch cannot be merged と書いてあった。にもかかわらず「パイプラインのタイミングだろう」と仮説を立て、エラーメッセージを読まずに3回修正した。

エラーメッセージと、プロジェクト設定(only_allow_merge_if_pipeline_succeeds: False)を最初に確認していれば、パイプラインは無関係だと1回でわかったはずだ。

GitLab APIで merge_statuschecking に変わるタイミングは公式ドキュメントに明記されていない。だがAPIレスポンスの merge_status フィールドは誰でも確認できる。次に422が返ったとき、最初に mr.merge_status を確認する。それだけで今回の3回分の遠回りはなくなる。