やる気駆動型エンジニアの備忘録

WPF(XAML+C#)の話題を中心に.NET/Android/CI やたまに趣味に関するブログです

Azure DevOps REST APIで大量のPull Requestを取得する際の注意点について

先日、Azure Repos で管理しているリポジトリの Complete 済みの全 Pull Request を取得したいという要件があったので Azure DevOps REST API を使いました。 その時、条件の指定方法によっては取得できる Pull Request の数が想定より少ない数しか取得できない、という現象が発生しました。 回避方法はあったのですが、割と使われやすい API であり、今後もハマリポイントになりそうなので備忘録として残しておきます。

今回使用する Azure DevOps REST API はこちら。

docs.microsoft.com

開発環境

今回は C# でしか検証していません。他の言語などでは挙動が変わる可能性があるのでご了承ください。 また、 Visual Studio CodeREST API でも試しましたが、こちらの場合は応答はありましたが結果が表示されないという現象が発生しました。原因については分かっていません。

問題点

まずはコードを。事前に NuGet でNewtonsoft.JsonSystem.Net.Httpを追加しておきます。

using System;
using System.Text;
using System.Net.Http;
using System.Threading.Tasks;
using System.Net.Http.Headers;

namespace AzureDevOps.Api
{
    class Program
    {
        static async Task Main(string[] args)
        {
            string auth = Convert.ToBase64String(Encoding.ASCII.GetBytes(":<Personal Access Token>"));            
            using (HttpClient httpClient = new HttpClient())
            {
                httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
                httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", auth);

                HttpRequestMessage message = new HttpRequestMessage(HttpMethod.Get, $"https://dev.azure.com/<organization>/<project>/_apis/git/repositories/<repository>/pullrequests?api-version=6.0");
                HttpResponseMessage response = await httpClient.SendAsync(message, HttpCompletionOption.ResponseContentRead);
                if (!response.IsSuccessStatusCode)
                {
                    throw new HttpRequestException(response.RequestMessage.ToString());
                }

                string json = await response.Content.ReadAsStringAsync();
                dynamic pullRequests = Newtonsoft.Json.JsonConvert.DeserializeObject(json);

                System.Console.WriteLine($"Pull-Requestの数:{pullRequests.count}");
            }
        }
    }
}

細かい説明は省きますが、目的は API で指定したリポジトリの Pull Request 数を出力しています。 検証対象の環境では Pull Request の数が 2000 を超えているものを使用しました。

この時点では、以下のように何の条件も指定していません。

https://dev.azure.com/<organization>/<project>/_apis/git/repositories/<repository>/pullrequests?api-version=6.0

これを実行すると以下のような結果が出力されます。

Pull-Requestの数:62

期待値としては 1000 以上ですが、そうはなっていません。

原因

ググると同じ問題にハマっている人がすぐに見つかりました。

stackoverflow.com

どうやらこの振る舞い自体は API 側の仕様のようで、条件が指定されていない場合、何千件もの Pull Request を返すことになるため返却される件数が少なくなっている(らしい)とのことです。 つまり、対策は上記のページにも書いてあるとおり、条件を指定して返却される件数を指定してやれば良いのです。

対策

今回のように Complete 済みの全 Pull Request を取得するといった要件の場合、次の URI パラメータを使用します。

  • $skip
  • $top
  • searchCriteria.status

実際の URI は次のようになります。

https://dev.azure.com/<organization>/<project>/_apis/git/repositories/<repository>/pullrequests?$skip=n&$top=1000&searchCriteria.status=completed&api-version=6.0

$skip=nとしているのは複数回 API を呼び出さなければならないためです。 さて、これに対応したコードは次のようになりました。

using System;
using System.Text;
using System.Net.Http;
using System.Threading.Tasks;
using System.Net.Http.Headers;

namespace AzureDevOps.Api
{
    class Program
    {
        static async Task Main(string[] args)
        {
            int totalCount = 0;
            string auth = Convert.ToBase64String(Encoding.ASCII.GetBytes(":<Personal Access Token>"));            
            using (HttpClient httpClient = new HttpClient())
            {
                httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
                httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", auth);

                int numberOfCalls = 0;
                do
                {
                    HttpRequestMessage message = new HttpRequestMessage(HttpMethod.Get, $"https://dev.azure.com/<organization>/<project>/_apis/git/repositories/<repository>/pullrequests?$skip={numberOfCalls * 1000}&$top=1000&searchCriteria.status=completed&api-version=6.0");
                    HttpResponseMessage response = await httpClient.SendAsync(message, HttpCompletionOption.ResponseContentRead);
                    if (!response.IsSuccessStatusCode)
                    {
                        throw new HttpRequestException(response.RequestMessage.ToString());
                    }

                    string json = await response.Content.ReadAsStringAsync();
                    dynamic pullRequests = Newtonsoft.Json.JsonConvert.DeserializeObject(json);
                    int count = (int)pullRequests.count;                        

                    if (count == 0) break;

                    totalCount += count;
                    numberOfCalls += 1;
                } while (true);
            }

            System.Console.WriteLine($"Pull-Requestの数:{totalCount}");
        }
    }
}

これで 1000 件を超える Pull Request の取得にも対応できます。

おまけ

今回作成したコードで API の条件に指定した件数によって実行時間がどのくらい変わるのか試してみました。 件数は 100 / 500 / 1000 で3回実施しました。

回数 100件 500件 1000件
00:00:21.7547365 00:00:39.0122263 00:00:36.1842337
00:00:32.3838879 00:00:38.4844625 00:00:34.9170869
00:00:32.5571534 00:00:38.5509034 00:00:28.5829396

微妙なところですが、まぁ、誤差の範囲かなーと思います。 取得件数による応答時間はあまり変わらないので、取得する際の要件次第で1回あたりの取得件数は変えるのが良さそうです。