Ruboty に GitHub の issue/pull request を検索する command を追加する


## はじめに

Qiita 社では Slack bot に Ruboty を :qiitan: Qiitan として利用しています。

https://qiita.com/tyamagu/items/9aa1bfd98d7ae69e614f

:qiitan: Qiitan (Ruboty) に GitHub の issue/pull request を検索させるというニーズが出てきたため、 "ruboty-qiita-github" に `search issues` command をさっと追加しました

今後 :qiitan: Qiitan (Ruboty) に新しい command を追加する時が来たら参考にしてください :information_desk_person:

## GitHub の issue/pull request を検索する command を追加する

### GitHub API を確認する

まず、 GitHub の issue/pull request を検索する Interface を確認します

GitHub REST API については、以下 GitHub Docs から探しましょう

https://docs.github.com/en/rest

今回必要な API は Search issues and pull requests です
必要な parameters や query parameters すべて言及することは難しいため 以下 docs を参照してください
[Search issues and pull requests | Search - GitHub Docs](https://docs.github.com/en/rest/search#search-issues-and-pull-requests)

また、 query parameters `q` については、以下 docs を参照することをおすすめです :thumbsup:
[Filtering and searching issues and pull requests - GitHub Docs](https://docs.github.com/en/issues/tracking-your-work-with-issues/filtering-and-searching-issues-and-pull-requests)

GitHub REST API に記載されている API は GitHub CLI で簡単に確認できるため確認してみましょう

今回は、 `usrt:mziyut` (@mziyut がもつ repository) の `is:open` (issue/pull request が Open されている) の `is:public` (public な repository) の issue/pull request を 1 件取得するという条件で issue/pull request を検索しました

```zsh
% gh api -H "Accept: application/vnd.github+json" '/search/issues?q=user:mziyut+is:public+is:open&per_page=1'
```

実行結果は以下のようになります

<details><summary>Click me</summary>

```json
{
  "total_count": 31,
  "incomplete_results": false,
  "items": [
    {
      "url": "https://api.github.com/repos/mziyut/honkit-plugin-prism/issues/7",
      "repository_url": "https://api.github.com/repos/mziyut/honkit-plugin-prism",
      "labels_url": "https://api.github.com/repos/mziyut/honkit-plugin-prism/issues/7/labels{/name}",
      "comments_url": "https://api.github.com/repos/mziyut/honkit-plugin-prism/issues/7/comments",
      "events_url": "https://api.github.com/repos/mziyut/honkit-plugin-prism/issues/7/events",
      "html_url": "https://github.com/mziyut/honkit-plugin-prism/pull/7",
      "id": 1410719907,
      "node_id": "PR_kwDOIJ_6M85A4onc",
      "number": 7,
      "title": "Bump @types/node from 18.8.3 to 18.11.0",
      "user": {
        "login": "dependabot[bot]",
        "id": 49699333,
        "node_id": "MDM6Qm90NDk2OTkzMzM=",
        "avatar_url": "https://avatars.githubusercontent.com/in/29110?v=4",
        "gravatar_id": "",
        "url": "https://api.github.com/users/dependabot%5Bbot%5D",
        "html_url": "https://github.com/apps/dependabot",
        "followers_url": "https://api.github.com/users/dependabot%5Bbot%5D/followers",
        "following_url": "https://api.github.com/users/dependabot%5Bbot%5D/following{/other_user}",
        "gists_url": "https://api.github.com/users/dependabot%5Bbot%5D/gists{/gist_id}",
        "starred_url": "https://api.github.com/users/dependabot%5Bbot%5D/starred{/owner}{/repo}",
        "subscriptions_url": "https://api.github.com/users/dependabot%5Bbot%5D/subscriptions",
        "organizations_url": "https://api.github.com/users/dependabot%5Bbot%5D/orgs",
        "repos_url": "https://api.github.com/users/dependabot%5Bbot%5D/repos",
        "events_url": "https://api.github.com/users/dependabot%5Bbot%5D/events{/privacy}",
        "received_events_url": "https://api.github.com/users/dependabot%5Bbot%5D/received_events",
        "type": "Bot",
        "site_admin": false
      },
      "labels": [
        {
          "id": 4678247549,
          "node_id": "LA_kwDOIJ_6M88AAAABFthkfQ",
          "url": "https://api.github.com/repos/mziyut/honkit-plugin-prism/labels/dependencies",
          "name": "dependencies",
          "color": "0366d6",
          "default": false,
          "description": "Pull requests that update a dependency file"
        },
        {
          "id": 4678247557,
          "node_id": "LA_kwDOIJ_6M88AAAABFthkhQ",
          "url": "https://api.github.com/repos/mziyut/honkit-plugin-prism/labels/javascript",
          "name": "javascript",
          "color": "168700",
          "default": false,
          "description": "Pull requests that update Javascript code"
        }
      ],
      "state": "open",
      "locked": false,
      "assignee": null,
      "assignees": [],
      "milestone": null,
      "comments": 0,
      "created_at": "2022-10-17T01:17:11Z",
      "updated_at": "2022-10-17T01:17:13Z",
      "closed_at": null,
      "author_association": "NONE",
      "active_lock_reason": null,
      "draft": false,
      "pull_request": {
        "url": "https://api.github.com/repos/mziyut/honkit-plugin-prism/pulls/7",
        "html_url": "https://github.com/mziyut/honkit-plugin-prism/pull/7",
        "diff_url": "https://github.com/mziyut/honkit-plugin-prism/pull/7.diff",
        "patch_url": "https://github.com/mziyut/honkit-plugin-prism/pull/7.patch",
        "merged_at": null
      },
      "body": "Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 18.8.3 to 18.11.0.\n<details>\n<summary>Commits</summary>\n<ul>\n<li>See full diff in <a href=\"https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node\">compare view</a></li>\n</ul>\n</details>\n<br />\n\n\n[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=@types/node&package-manager=npm_and_yarn&previous-version=18.8.3&new-version=18.11.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)\n\nDependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`.\n\n[//]: # (dependabot-automerge-start)\n[//]: # (dependabot-automerge-end)\n\n---\n\n<details>\n<summary>Dependabot commands and options</summary>\n<br />\n\nYou can trigger Dependabot actions by commenting on this PR:\n- `@dependabot rebase` will rebase this PR\n- `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it\n- `@dependabot merge` will merge this PR after your CI passes on it\n- `@dependabot squash and merge` will squash and merge this PR after your CI passes on it\n- `@dependabot cancel merge` will cancel a previously requested merge and block automerging\n- `@dependabot reopen` will reopen this PR if it is closed\n- `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually\n- `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)\n- `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)\n- `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)\n\n\n</details>",
      "reactions": {
        "url": "https://api.github.com/repos/mziyut/honkit-plugin-prism/issues/7/reactions",
        "total_count": 0,
        "+1": 0,
        "-1": 0,
        "laugh": 0,
        "hooray": 0,
        "confused": 0,
        "heart": 0,
        "rocket": 0,
        "eyes": 0
      },
      "timeline_url": "https://api.github.com/repos/mziyut/honkit-plugin-prism/issues/7/timeline",
      "performed_via_github_app": null,
      "state_reason": null,
      "score": 1.0
    }
  ]
}
```

</details>

今回、 Issue/Pull request の検索に必要な API が定義され、返却される項目が理解できたら次のステップに進みましょう

### Gem `octkit` を確認する

`ruboty-qiita-github` では `octokit/octokit.rb` を利用し GitHub API 経由でデータを取得・操作しています

https://github.com/octokit/octokit.rb

```ruby:lib/octokit/client/search.rb#L37-L49
      # Search issues
      #
      # @param query [String] Search term and qualifiers
      # @param options [Hash] Sort and pagination options
      # @option options [String] :sort Sort field
      # @option options [String] :order Sort order (asc or desc)
      # @option options [Integer] :page Page of paginated results
      # @option options [Integer] :per_page Number of items per page
      # @return [Sawyer::Resource] Search results object
      # @see https://developer.github.com/v3/search/#search-issues-and-pull-requests
      def search_issues(query, options = {})
        search 'search/issues', query, options
      end
```

https://github.com/octokit/octokit.rb/blob/e915332a3ab7aa2de3442b0a7fedd6b6ecb3b928/lib/octokit/client/search.rb#L37-L49

先ほど GitHub REST API で確認した endpoint を叩けることが確認できました。

### `ruboty-qiita-github` に command を追加する

必要な GitHub API が確認できたら `ruboty-qiita-github` に command を定義します
command を定義するために以下ファイルを変更、作成しましょう

- 新規作成
  - `lib/ruboty/github/actions/search_issues.rb`
- 変更
  - `lib/ruboty/handlers/github.rb`
  - `lib/ruboty/github.rb`

実際のコードを例に役割など説明していきます

#### `lib/ruboty/github/actions/search_issues.rb`

まず先に、 `search issues` の処理を定義しましょう
`lib/ruboty/github/actions/search_issues.rb` を新規作成します

処理の流れとしては...

- GitHub Token が登録されているか確認
  - 登録されていない場合、エラーを返却
- octkit (code 上は client) 経由で search_issues API からデータを取得する
- 結果を整形して、返却する

```ruby:lib/ruboty/github/actions/search_issues.rb
# frozen_string_literal: true

module Ruboty
  module Github
    module Actions
      class SearchIssues < Base
        def call
          if has_access_token?
            search
          else
            require_access_token
          end
        end

        private

        def search
          results = search_issues.items.each_with_object(["Searched: '#{query}'"]) do |item, object|
            repository_url = item[:repository_url].delete_prefix('https://api.github.com/repos/')

            object << "[#{repository_url}##{item[:number]}] #{item[:title]} (#{item[:user][:login]})\n#{item[:html_url]}"
          end

          message.reply(results.join("\n"))
        rescue Octokit::Unauthorized
          message.reply('Failed in authentication (401)')
        rescue Octokit::NotFound
          message.reply('Could not find that repository')
        rescue StandardError => e
          message.reply("Failed by #{e.class}")
        end

        def query
          message[:query]
        end

        def search_issues
          client.search_issues(query)
        end
      end
    end
  end
end
```

https://github.com/increments/ruboty-qiita-github/blob/7df067035fd2f735359980b1265e613029334feb/lib/ruboty/github/actions/search_issues.rb#L1-L43

処理が定義できたら次のステップに進みましょう

#### `lib/ruboty/github.rb`

先ほど作成した `lib/ruboty/github/actions/search_issues.rb` を require します

```:lib/ruboty/github.rb
require 'ruboty/github/actions/search_issues'
```

https://github.com/increments/ruboty-qiita-github/blob/7df067035fd2f735359980b1265e613029334feb/lib/ruboty/github.rb#L14

#### `lib/ruboty/handlers/github.rb`

`lib/ruboty/handlers/github.rb` は Ruboty が受け取ったコマンドを定義された process に dispatch する役割を担っています
今回追加した処理は以下 2 つです

```ruby:lib/ruboty/handlers/github.rb#L14-L18
      on(
        /search issues "(?<query>.+)"/,  # 追加する command の条件を記載
        name: "search_issues",           # dispatch する method 名を記載
        description: "Search an issue",  # command の説明を記載
      )
```

```ruby:lib/ruboty/handlers/github.rb#L54-L56
      def search_issues(message)
        Ruboty::Github::Actions::SearchIssues.new(message).call
      end
```

ここまでで、 Ruboty に対して `search issues "user:foo"` 等と送信すると指定した処理が実行されるようになります

#### その他

必要に応じて今回追加した command に対するテストも書きましょう :muscle:

##### `spec/ruboty/handlers/github_spec.rb`

https://github.com/increments/ruboty-qiita-github/blob/a3fb3554d9e7bcf5b4e8cfdb7070ae7f92120f88/spec/ruboty/handlers/github_spec.rb#L92-L124

## 最後に

今回は Ruboty の plugin である `ruboty-qiita-github` に簡単な command を追加してみました
コマンドを追加するだけであれば簡単なので Ruboty の plugin の開発に try してみてはいかがでしょうか

## Reference

https://github.com/increments/ruboty-qiita-github/pull/7

https://github.com/increments/ruboty-qiita-github