[GitHub API v3] 指定のPull RequestのLinked Issueを取得する

2021.08.31

こんにちは、CX事業本部 IoT事業部の若槻です。

最近、Pull Requestでの開発の自動化のために、「Pull Requestの変更をデプロイ(≠マージ)をしたらLinked Issueに必要な情報を書き込む」という処理を実装する必要がありました。(あまり無いパターンかも知れませんが。)

そこで今回は、GitHub API v3で指定のPull RequestのLinked Issueを取得する方法を確認してみました。

Linked Issueとは?

Pull RequestのBodyにclose #{Issue番号}と記載すると、そのIssueはPull Requestの「Linked Issue」となります。このPull RequestをCloseすると、Linked Issueも合わせてCloseされるため、Issueで機能開発やバグフィックスを管理する場合に便利です。

Linkされた側のIssueではLink元の「Linked Puull Request」が確認できます。

やってみた

アクセストークンの発行

まだの場合は下記から発行します。

スコープはrepoを指定します。(repo:statusだけだと後述のAPI操作で権限不足となります。)

Pull Requestを取得する

指定のPull Requestの情報は、GitHub API v3の下記のエンドポイントを使用すれば取得可能です。

GET /repos/{owner}/{repo}/pulls/{pull_number}

取得してみます。

% GITHUB_ACCESS_AOKEN=##GITHUB_ACCESS_AOKEN##
% OWNER=cm-rwakatsuki
% REPO=test
% pull_number=22
% result=$(curl \
  -H "Accept: application/vnd.github.v3+json" \
  -H "Authorization: token ${GITHUB_ACCESS_AOKEN}" \
  "https://api.github.com/repos/${OWNER}/${REPO}/pulls/${pull_number}")

取得結果のJSONは下記のようになります。

$ echo ${result} > get_pull_result.json

get_pull_result.json

{
  "url": "https://api.github.com/repos/cm-rwakatsuki/test/pulls/22",
  "id": 723439756,
  "node_id": "MDExOlB1bGxSZXF1ZXN0NzIzNDM5NzU2",
  "html_url": "https://github.com/cm-rwakatsuki/test/pull/22",
  "diff_url": "https://github.com/cm-rwakatsuki/test/pull/22.diff",
  "patch_url": "https://github.com/cm-rwakatsuki/test/pull/22.patch",
  "issue_url": "https://api.github.com/repos/cm-rwakatsuki/test/issues/22",
  "number": 22,
  "state": "open",
  "locked": false,
  "title": "Develop",
  "user": {
    "login": "cm-rwakatsuki",
    "id": 57384023,
    "node_id": "MDQ6VXNlcjU3Mzg0MDIz",
    "avatar_url": "https://avatars.githubusercontent.com/u/57384023?v=4",
    "gravatar_id": "",
    "url": "https://api.github.com/users/cm-rwakatsuki",
    "html_url": "https://github.com/cm-rwakatsuki",
    "followers_url": "https://api.github.com/users/cm-rwakatsuki/followers",
    "following_url": "https://api.github.com/users/cm-rwakatsuki/following{/other_user}",
    "gists_url": "https://api.github.com/users/cm-rwakatsuki/gists{/gist_id}",
    "starred_url": "https://api.github.com/users/cm-rwakatsuki/starred{/owner}{/repo}",
    "subscriptions_url": "https://api.github.com/users/cm-rwakatsuki/subscriptions",
    "organizations_url": "https://api.github.com/users/cm-rwakatsuki/orgs",
    "repos_url": "https://api.github.com/users/cm-rwakatsuki/repos",
    "events_url": "https://api.github.com/users/cm-rwakatsuki/events{/privacy}",
    "received_events_url": "https://api.github.com/users/cm-rwakatsuki/received_events",
    "type": "User",
    "site_admin": false
  },
  "body": "close #23",
  "created_at": "2021-08-31T14:37:26Z",
  "updated_at": "2021-08-31T15:01:54Z",
  "closed_at": null,
  "merged_at": null,
  "merge_commit_sha": "6401b9ab1edc6e700343b26ab1132bf7f1234670",
  "assignee": null,
  "assignees": [

  ],
  "requested_reviewers": [

  ],
  "requested_teams": [

  ],
  "labels": [

  ],
  "milestone": null,
  "draft": false,
  "commits_url": "https://api.github.com/repos/cm-rwakatsuki/test/pulls/22/commits",
  "review_comments_url": "https://api.github.com/repos/cm-rwakatsuki/test/pulls/22/comments",
  "review_comment_url": "https://api.github.com/repos/cm-rwakatsuki/test/pulls/comments{/number}",
  "comments_url": "https://api.github.com/repos/cm-rwakatsuki/test/issues/22/comments",
  "statuses_url": "https://api.github.com/repos/cm-rwakatsuki/test/statuses/5e34d6c373c794e5142909259659b7fc03f4bb30",
  "head": {
    "label": "cm-rwakatsuki:develop",
    "ref": "develop",
    "sha": "5e34d6c373c794e5142909259659b7fc03f4bb30",
    "user": {
      "login": "cm-rwakatsuki",
      "id": 57384023,
      "node_id": "MDQ6VXNlcjU3Mzg0MDIz",
      "avatar_url": "https://avatars.githubusercontent.com/u/57384023?v=4",
      "gravatar_id": "",
      "url": "https://api.github.com/users/cm-rwakatsuki",
      "html_url": "https://github.com/cm-rwakatsuki",
      "followers_url": "https://api.github.com/users/cm-rwakatsuki/followers",
      "following_url": "https://api.github.com/users/cm-rwakatsuki/following{/other_user}",
      "gists_url": "https://api.github.com/users/cm-rwakatsuki/gists{/gist_id}",
      "starred_url": "https://api.github.com/users/cm-rwakatsuki/starred{/owner}{/repo}",
      "subscriptions_url": "https://api.github.com/users/cm-rwakatsuki/subscriptions",
      "organizations_url": "https://api.github.com/users/cm-rwakatsuki/orgs",
      "repos_url": "https://api.github.com/users/cm-rwakatsuki/repos",
      "events_url": "https://api.github.com/users/cm-rwakatsuki/events{/privacy}",
      "received_events_url": "https://api.github.com/users/cm-rwakatsuki/received_events",
      "type": "User",
      "site_admin": false
    },
    "repo": {
      "id": 302627041,
      "node_id": "MDEwOlJlcG9zaXRvcnkzMDI2MjcwNDE=",
      "name": "test",
      "full_name": "cm-rwakatsuki/test",
      "private": true,
      "owner": {
        "login": "cm-rwakatsuki",
        "id": 57384023,
        "node_id": "MDQ6VXNlcjU3Mzg0MDIz",
        "avatar_url": "https://avatars.githubusercontent.com/u/57384023?v=4",
        "gravatar_id": "",
        "url": "https://api.github.com/users/cm-rwakatsuki",
        "html_url": "https://github.com/cm-rwakatsuki",
        "followers_url": "https://api.github.com/users/cm-rwakatsuki/followers",
        "following_url": "https://api.github.com/users/cm-rwakatsuki/following{/other_user}",
        "gists_url": "https://api.github.com/users/cm-rwakatsuki/gists{/gist_id}",
        "starred_url": "https://api.github.com/users/cm-rwakatsuki/starred{/owner}{/repo}",
        "subscriptions_url": "https://api.github.com/users/cm-rwakatsuki/subscriptions",
        "organizations_url": "https://api.github.com/users/cm-rwakatsuki/orgs",
        "repos_url": "https://api.github.com/users/cm-rwakatsuki/repos",
        "events_url": "https://api.github.com/users/cm-rwakatsuki/events{/privacy}",
        "received_events_url": "https://api.github.com/users/cm-rwakatsuki/received_events",
        "type": "User",
        "site_admin": false
      },
      "html_url": "https://github.com/cm-rwakatsuki/test",
      "description": null,
      "fork": false,
      "url": "https://api.github.com/repos/cm-rwakatsuki/test",
      "forks_url": "https://api.github.com/repos/cm-rwakatsuki/test/forks",
      "keys_url": "https://api.github.com/repos/cm-rwakatsuki/test/keys{/key_id}",
      "collaborators_url": "https://api.github.com/repos/cm-rwakatsuki/test/collaborators{/collaborator}",
      "teams_url": "https://api.github.com/repos/cm-rwakatsuki/test/teams",
      "hooks_url": "https://api.github.com/repos/cm-rwakatsuki/test/hooks",
      "issue_events_url": "https://api.github.com/repos/cm-rwakatsuki/test/issues/events{/number}",
      "events_url": "https://api.github.com/repos/cm-rwakatsuki/test/events",
      "assignees_url": "https://api.github.com/repos/cm-rwakatsuki/test/assignees{/user}",
      "branches_url": "https://api.github.com/repos/cm-rwakatsuki/test/branches{/branch}",
      "tags_url": "https://api.github.com/repos/cm-rwakatsuki/test/tags",
      "blobs_url": "https://api.github.com/repos/cm-rwakatsuki/test/git/blobs{/sha}",
      "git_tags_url": "https://api.github.com/repos/cm-rwakatsuki/test/git/tags{/sha}",
      "git_refs_url": "https://api.github.com/repos/cm-rwakatsuki/test/git/refs{/sha}",
      "trees_url": "https://api.github.com/repos/cm-rwakatsuki/test/git/trees{/sha}",
      "statuses_url": "https://api.github.com/repos/cm-rwakatsuki/test/statuses/{sha}",
      "languages_url": "https://api.github.com/repos/cm-rwakatsuki/test/languages",
      "stargazers_url": "https://api.github.com/repos/cm-rwakatsuki/test/stargazers",
      "contributors_url": "https://api.github.com/repos/cm-rwakatsuki/test/contributors",
      "subscribers_url": "https://api.github.com/repos/cm-rwakatsuki/test/subscribers",
      "subscription_url": "https://api.github.com/repos/cm-rwakatsuki/test/subscription",
      "commits_url": "https://api.github.com/repos/cm-rwakatsuki/test/commits{/sha}",
      "git_commits_url": "https://api.github.com/repos/cm-rwakatsuki/test/git/commits{/sha}",
      "comments_url": "https://api.github.com/repos/cm-rwakatsuki/test/comments{/number}",
      "issue_comment_url": "https://api.github.com/repos/cm-rwakatsuki/test/issues/comments{/number}",
      "contents_url": "https://api.github.com/repos/cm-rwakatsuki/test/contents/{+path}",
      "compare_url": "https://api.github.com/repos/cm-rwakatsuki/test/compare/{base}...{head}",
      "merges_url": "https://api.github.com/repos/cm-rwakatsuki/test/merges",
      "archive_url": "https://api.github.com/repos/cm-rwakatsuki/test/{archive_format}{/ref}",
      "downloads_url": "https://api.github.com/repos/cm-rwakatsuki/test/downloads",
      "issues_url": "https://api.github.com/repos/cm-rwakatsuki/test/issues{/number}",
      "pulls_url": "https://api.github.com/repos/cm-rwakatsuki/test/pulls{/number}",
      "milestones_url": "https://api.github.com/repos/cm-rwakatsuki/test/milestones{/number}",
      "notifications_url": "https://api.github.com/repos/cm-rwakatsuki/test/notifications{?since,all,participating}",
      "labels_url": "https://api.github.com/repos/cm-rwakatsuki/test/labels{/name}",
      "releases_url": "https://api.github.com/repos/cm-rwakatsuki/test/releases{/id}",
      "deployments_url": "https://api.github.com/repos/cm-rwakatsuki/test/deployments",
      "created_at": "2020-10-09T11:54:38Z",
      "updated_at": "2021-07-14T16:52:42Z",
      "pushed_at": "2021-08-31T14:37:26Z",
      "git_url": "git://github.com/cm-rwakatsuki/test.git",
      "ssh_url": "git@github.com:cm-rwakatsuki/test.git",
      "clone_url": "https://github.com/cm-rwakatsuki/test.git",
      "svn_url": "https://github.com/cm-rwakatsuki/test",
      "homepage": null,
      "size": 61,
      "stargazers_count": 0,
      "watchers_count": 0,
      "language": "Shell",
      "has_issues": true,
      "has_projects": true,
      "has_downloads": true,
      "has_wiki": true,
      "has_pages": false,
      "forks_count": 0,
      "mirror_url": null,
      "archived": false,
      "disabled": false,
      "open_issues_count": 8,
      "license": null,
      "forks": 0,
      "open_issues": 8,
      "watchers": 0,
      "default_branch": "main"
    }
  },
  "base": {
    "label": "cm-rwakatsuki:main",
    "ref": "main",
    "sha": "50adcafd9bd8b662af5ee34c0456663e34cd940d",
    "user": {
      "login": "cm-rwakatsuki",
      "id": 57384023,
      "node_id": "MDQ6VXNlcjU3Mzg0MDIz",
      "avatar_url": "https://avatars.githubusercontent.com/u/57384023?v=4",
      "gravatar_id": "",
      "url": "https://api.github.com/users/cm-rwakatsuki",
      "html_url": "https://github.com/cm-rwakatsuki",
      "followers_url": "https://api.github.com/users/cm-rwakatsuki/followers",
      "following_url": "https://api.github.com/users/cm-rwakatsuki/following{/other_user}",
      "gists_url": "https://api.github.com/users/cm-rwakatsuki/gists{/gist_id}",
      "starred_url": "https://api.github.com/users/cm-rwakatsuki/starred{/owner}{/repo}",
      "subscriptions_url": "https://api.github.com/users/cm-rwakatsuki/subscriptions",
      "organizations_url": "https://api.github.com/users/cm-rwakatsuki/orgs",
      "repos_url": "https://api.github.com/users/cm-rwakatsuki/repos",
      "events_url": "https://api.github.com/users/cm-rwakatsuki/events{/privacy}",
      "received_events_url": "https://api.github.com/users/cm-rwakatsuki/received_events",
      "type": "User",
      "site_admin": false
    },
    "repo": {
      "id": 302627041,
      "node_id": "MDEwOlJlcG9zaXRvcnkzMDI2MjcwNDE=",
      "name": "test",
      "full_name": "cm-rwakatsuki/test",
      "private": true,
      "owner": {
        "login": "cm-rwakatsuki",
        "id": 57384023,
        "node_id": "MDQ6VXNlcjU3Mzg0MDIz",
        "avatar_url": "https://avatars.githubusercontent.com/u/57384023?v=4",
        "gravatar_id": "",
        "url": "https://api.github.com/users/cm-rwakatsuki",
        "html_url": "https://github.com/cm-rwakatsuki",
        "followers_url": "https://api.github.com/users/cm-rwakatsuki/followers",
        "following_url": "https://api.github.com/users/cm-rwakatsuki/following{/other_user}",
        "gists_url": "https://api.github.com/users/cm-rwakatsuki/gists{/gist_id}",
        "starred_url": "https://api.github.com/users/cm-rwakatsuki/starred{/owner}{/repo}",
        "subscriptions_url": "https://api.github.com/users/cm-rwakatsuki/subscriptions",
        "organizations_url": "https://api.github.com/users/cm-rwakatsuki/orgs",
        "repos_url": "https://api.github.com/users/cm-rwakatsuki/repos",
        "events_url": "https://api.github.com/users/cm-rwakatsuki/events{/privacy}",
        "received_events_url": "https://api.github.com/users/cm-rwakatsuki/received_events",
        "type": "User",
        "site_admin": false
      },
      "html_url": "https://github.com/cm-rwakatsuki/test",
      "description": null,
      "fork": false,
      "url": "https://api.github.com/repos/cm-rwakatsuki/test",
      "forks_url": "https://api.github.com/repos/cm-rwakatsuki/test/forks",
      "keys_url": "https://api.github.com/repos/cm-rwakatsuki/test/keys{/key_id}",
      "collaborators_url": "https://api.github.com/repos/cm-rwakatsuki/test/collaborators{/collaborator}",
      "teams_url": "https://api.github.com/repos/cm-rwakatsuki/test/teams",
      "hooks_url": "https://api.github.com/repos/cm-rwakatsuki/test/hooks",
      "issue_events_url": "https://api.github.com/repos/cm-rwakatsuki/test/issues/events{/number}",
      "events_url": "https://api.github.com/repos/cm-rwakatsuki/test/events",
      "assignees_url": "https://api.github.com/repos/cm-rwakatsuki/test/assignees{/user}",
      "branches_url": "https://api.github.com/repos/cm-rwakatsuki/test/branches{/branch}",
      "tags_url": "https://api.github.com/repos/cm-rwakatsuki/test/tags",
      "blobs_url": "https://api.github.com/repos/cm-rwakatsuki/test/git/blobs{/sha}",
      "git_tags_url": "https://api.github.com/repos/cm-rwakatsuki/test/git/tags{/sha}",
      "git_refs_url": "https://api.github.com/repos/cm-rwakatsuki/test/git/refs{/sha}",
      "trees_url": "https://api.github.com/repos/cm-rwakatsuki/test/git/trees{/sha}",
      "statuses_url": "https://api.github.com/repos/cm-rwakatsuki/test/statuses/{sha}",
      "languages_url": "https://api.github.com/repos/cm-rwakatsuki/test/languages",
      "stargazers_url": "https://api.github.com/repos/cm-rwakatsuki/test/stargazers",
      "contributors_url": "https://api.github.com/repos/cm-rwakatsuki/test/contributors",
      "subscribers_url": "https://api.github.com/repos/cm-rwakatsuki/test/subscribers",
      "subscription_url": "https://api.github.com/repos/cm-rwakatsuki/test/subscription",
      "commits_url": "https://api.github.com/repos/cm-rwakatsuki/test/commits{/sha}",
      "git_commits_url": "https://api.github.com/repos/cm-rwakatsuki/test/git/commits{/sha}",
      "comments_url": "https://api.github.com/repos/cm-rwakatsuki/test/comments{/number}",
      "issue_comment_url": "https://api.github.com/repos/cm-rwakatsuki/test/issues/comments{/number}",
      "contents_url": "https://api.github.com/repos/cm-rwakatsuki/test/contents/{+path}",
      "compare_url": "https://api.github.com/repos/cm-rwakatsuki/test/compare/{base}...{head}",
      "merges_url": "https://api.github.com/repos/cm-rwakatsuki/test/merges",
      "archive_url": "https://api.github.com/repos/cm-rwakatsuki/test/{archive_format}{/ref}",
      "downloads_url": "https://api.github.com/repos/cm-rwakatsuki/test/downloads",
      "issues_url": "https://api.github.com/repos/cm-rwakatsuki/test/issues{/number}",
      "pulls_url": "https://api.github.com/repos/cm-rwakatsuki/test/pulls{/number}",
      "milestones_url": "https://api.github.com/repos/cm-rwakatsuki/test/milestones{/number}",
      "notifications_url": "https://api.github.com/repos/cm-rwakatsuki/test/notifications{?since,all,participating}",
      "labels_url": "https://api.github.com/repos/cm-rwakatsuki/test/labels{/name}",
      "releases_url": "https://api.github.com/repos/cm-rwakatsuki/test/releases{/id}",
      "deployments_url": "https://api.github.com/repos/cm-rwakatsuki/test/deployments",
      "created_at": "2020-10-09T11:54:38Z",
      "updated_at": "2021-07-14T16:52:42Z",
      "pushed_at": "2021-08-31T14:37:26Z",
      "git_url": "git://github.com/cm-rwakatsuki/test.git",
      "ssh_url": "git@github.com:cm-rwakatsuki/test.git",
      "clone_url": "https://github.com/cm-rwakatsuki/test.git",
      "svn_url": "https://github.com/cm-rwakatsuki/test",
      "homepage": null,
      "size": 61,
      "stargazers_count": 0,
      "watchers_count": 0,
      "language": "Shell",
      "has_issues": true,
      "has_projects": true,
      "has_downloads": true,
      "has_wiki": true,
      "has_pages": false,
      "forks_count": 0,
      "mirror_url": null,
      "archived": false,
      "disabled": false,
      "open_issues_count": 8,
      "license": null,
      "forks": 0,
      "open_issues": 8,
      "watchers": 0,
      "default_branch": "main"
    }
  },
  "_links": {
    "self": {
      "href": "https://api.github.com/repos/cm-rwakatsuki/test/pulls/22"
    },
    "html": {
      "href": "https://github.com/cm-rwakatsuki/test/pull/22"
    },
    "issue": {
      "href": "https://api.github.com/repos/cm-rwakatsuki/test/issues/22"
    },
    "comments": {
      "href": "https://api.github.com/repos/cm-rwakatsuki/test/issues/22/comments"
    },
    "review_comments": {
      "href": "https://api.github.com/repos/cm-rwakatsuki/test/pulls/22/comments"
    },
    "review_comment": {
      "href": "https://api.github.com/repos/cm-rwakatsuki/test/pulls/comments{/number}"
    },
    "commits": {
      "href": "https://api.github.com/repos/cm-rwakatsuki/test/pulls/22/commits"
    },
    "statuses": {
      "href": "https://api.github.com/repos/cm-rwakatsuki/test/statuses/5e34d6c373c794e5142909259659b7fc03f4bb30"
    }
  },
  "author_association": "OWNER",
  "auto_merge": null,
  "active_lock_reason": null,
  "merged": false,
  "mergeable": true,
  "rebaseable": true,
  "mergeable_state": "clean",
  "merged_by": null,
  "comments": 0,
  "review_comments": 0,
  "maintainer_can_modify": false,
  "commits": 7,
  "additions": 0,
  "deletions": 0,
  "changed_files": 1
}

指定のPull Requestの情報が取得できました。

Pull RequestのLinked Issueを取得

あとは前述のPull Requestの取得結果からLinked Issueの情報を取得すればOKだと思いきやここで問題発生です。Pull Requestの取得結果の中にLinked Issueについてのプロパティがなぜか見当たりません。どうやらAPIで取得したPull RequestのデータにはLinked Issueの情報が含まれないようです。

これに関してはGitHubのCommunityでも取り沙汰されていますが、現在もなお実装に至っていないようです。

仕方がないので.bodyclose #{pull number}の文字列からgrepを使用して取得します。

$ echo ${result} | jq .body -r | grep "close #[0-9]\+" | grep -o "[0-9]\+"
23

取得できました。

おわりに

GitHub API v3で指定のPull RequestのLinked Issueを取得してみました。

Linked Issueの情報はGUI上では表示されているのに、APIで取得した結果には含まれないというのは納得がいきませんが、スマートで無いながらも取得方法が見つけられて良かったです。

参考

以上