GitHub CopilotのEdit with Copilotを使ってみよう

## はじめに

GitHub Copilotは、AIによるコード補完機能を提供するVisual Studio Codeの拡張機能です。GitHub Copilotは、コード補完機能を提供するだけでなく、対話形式でコードの生成や修正を行うこともできます。この記事では、GitHub Copilotの「Edit with Copilot」機能を使って、コードの生成や修正を行う方法について紹介します。

## Edit with Copilotの使い方

VSCodeにGitHub CopilotのExtensionをインストールすると、Edit with Copilot機能が利用できるようになります。Edit with Copilotの基本的な使い方は以下の通りです。

1. **以下のいずれかの方法でCopilot Editsビューを開く**:
    - `ctrl + shift + I` キーボードショートカットを使用する
    - コマンドセンターのCopilotメニューを開き、**Open Copilot Edits**を選択する
    - コマンドパレットを開き **View: Toggle Copilot Edits** または **Copilot Edits: Focus on Copilot Edits View** コマンドを使用する
    - チャットビューで **Edit with Copilot** を選択して、以前のチャット会話をCopilot Editsに移動する

2. **作業セットにファイルを追加する**:
    - **Add Files...** を選択し、編集したいファイルを選択する

3. **コード編集をリクエストする**:
    - 自然言語で具体的な編集内容をチャットプロンプトに入力する。例えば、「連絡先ページにメール、電話番号、郵送先住所を追加する」や「すべての単体テストをvitestに変換する」など。

4. **生成された編集を保存する**:
    - 編集されたファイルを個別に保存するか、作業セットで **Save All** を選択して、編集内容をディスクに保存する

5. **編集を受け入れるまたは破棄する**:
    - エディタオーバーレイコントロールを使用して、各ファイルのAI生成編集を受け入れるか破棄する

6. **編集を元に戻す**:
    - Copilot Editsビューのタイトルバーにある **Undo Last Edit** コントロールを使用して、最後の編集を元に戻す

7. **編集セッションを終了する**:
    - Copilot Editsビューのタイトルバーにある **Done** ボタンを押すと編集セッションを終了し、選択されていたファイルや対話履歴がリセットされます。

https://code.visualstudio.com/docs/copilot/copilot-edits

## おすすめの使い方

私は、Edit with Copilot機能を、作業スクリプトを作成するのによく使っています。

今回は例として、ディレクトリをトラバースして、mdファイルの一覧をCSVファイルにまとめるスクリプトを作成してみます。

### スクリプトの作成
まず、作業するスクリプト（pythonまたはbashスクリプト）を新規作成します。今回はpythonスクリプト(traverse.py)を作成します。

スクリプトの先頭に、スクリプトの目的や仕様をコメントで書きます。


```python:traverse.py

"""
このスクリプトでは、指定されたディレクトリをトラバースして、mdファイルの一覧をCSVファイルにまとめます。

## 入力
コマンドライン引数で入力を受け取ります。

- workdir: トラバースするディレクトリ
- output: 出力するCSVファイルのパス

指定されなかった場合は、ヘルプを表示します。

## 処理

1. 指定されたディレクトリをトラバースする
2. mdファイルを見つけたら以下の処理を行う
    - ファイル名を取得する
    - ファイルのタイトルを取得する
3. CSVファイルにファイル名とタイトルを書き込む

### タイトルの取得ロジック
以下の順でタイトルを取得します。

1. Metadata Blockのtitleフィールド
2. 先頭の見出し
3. ファイル名から拡張子を除いた部分

### 使用するライブラリ
- YAMLファイルの読み書き: PyYAML
- Markdownファイルのパース: ライブラリは使用せず、正規表現で処理する

## 出力

CSVファイルにファイル名とタイトルが書き込まれます。
CSVのヘッダは、”relative path", "filename", "title"です。
"""
```

このように、スクリプトの仕様を過不足なくコメントで書いておくと、Edit with Copilot機能が生成するコードが期待通りになることが多いです。

次に、Edit with Copilot機能を使って、スクリプトのコードを生成します。

1. **Copilot Editsビューを開く**:
    - `ctrl + shift + I` キーボードショートカットを使用する
2. **作業セットにファイルを追加する**:
    - **Add Files...** を選択し、作成したスクリプトファイルを選択する
3. **コード編集をリクエストする**:
    - 「traverse.pyを使用に従って実装して」というプロンプトを入力する編集をリクエストします。
4. **生成された編集を保存する**:
    - 編集されたファイルを保存します。
    - 生成されたコードをAcceptします。

実際に生成されたコードは以下の通りです。

```python:traverse.py

import os
import csv
import re
import sys
import yaml

def get_title_from_metadata(content):
    try:
        metadata = yaml.safe_load(content)
        if 'title' in metadata:
            return metadata['title']
    except yaml.YAMLError:
        pass
    return None

def get_title_from_heading(content):
    match = re.search(r'^# (.+)', content, re.MULTILINE)
    if match:
        return match.group(1)
    return None

def get_title(file_path):
    with open(file_path, 'r', encoding='utf-8') as f:
        content = f.read()
        title = get_title_from_metadata(content)
        if title:
            return title
        title = get_title_from_heading(content)
        if title:
            return title
    return os.path.splitext(os.path.basename(file_path))[0]

def traverse_directory(workdir, output):
    with open(output, 'w', newline='', encoding='utf-8') as csvfile:
        fieldnames = ['relative path', 'filename', 'title']
        writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
        writer.writeheader()

        for root, _, files in os.walk(workdir):
            for file in files:
                if file.endswith('.md'):
                    file_path = os.path.join(root, file)
                    relative_path = os.path.relpath(file_path, workdir)
                    title = get_title(file_path)
                    writer.writerow({'relative path': relative_path, 'filename': file, 'title': title})

def main():
    if len(sys.argv) != 3:
        print("Usage: python traverse.py <workdir> <output>")
        sys.exit(1)

    workdir = sys.argv[1]
    output = sys.argv[2]

    traverse_directory(workdir, output)

if __name__ == "__main__":
    main()
```

実際に動かしてみたところ、うまく動いていないようなので、テストを書いてデバッグしましょう。

### テストの作成
テストコードを書くためのファイルを作成します。ここでは、pytestを使ってテストコードを書いてみます。
test_traverse.pyを作成して、Add filesからファイルをEditのWorking Setに追加します。「get_titleの単体テストをpytestを使って作成して」とリクエストします。

生成されたテストコードは以下の通りです。仕様に従って網羅的なテストが書かれていることがわかります。

```python:test_traverse.py
import pytest
from traverse import get_title

def test_get_title_from_metadata(tmp_path):
    content = "---\ntitle: Test Title\n---\n# Heading"
    file_path = tmp_path / "test.md"
    file_path.write_text(content, encoding='utf-8')
    assert get_title(file_path) == "Test Title"

def test_get_title_from_heading(tmp_path):
    content = "# Heading\nContent"
    file_path = tmp_path / "test.md"
    file_path.write_text(content, encoding='utf-8')
    assert get_title(file_path) == "Heading"

def test_get_title_from_filename(tmp_path):
    content = "Content without title or heading"
    file_path = tmp_path / "test.md"
    file_path.write_text(content, encoding='utf-8')
    assert get_title(file_path) == "test"

def test_get_title_with_invalid_metadata(tmp_path):
    content = "---\ntitle: [Test Title\n---\n# Heading"
    file_path = tmp_path / "test.md"
    file_path.write_text(content, encoding='utf-8')
    assert get_title(file_path) == "Heading"
```

### デバッグする

テストを実行して、デバッグを行います。

このステップではGitHub Copilotはまだそこまで有効ではないです。ステップ実行などをして、エラーの原因を特定しましょう。

今回の場合は、metadataの読み込み時に、先頭の`---`が正しくパースされていないことが原因でした。この問題を修正して、再度テストを実行します。

Before:

```python

def get_title(file_path):
    with open(file_path, 'r', encoding='utf-8') as f:
        content = f.read()
        title = get_title_from_metadata(content)
        if title:
            return title
        title = get_title_from_heading(content)
        if title:
            return title
    return os.path.splitext(os.path.basename(file_path))[0]
```

After:

```python
def get_title(file_path):
    with open(file_path, 'r', encoding='utf-8') as f:
        content = f.read()
        # split metadata block and content
        if content.startswith('---'):
            metadata_content = content.split('---', 2)[1]
            content = content.split('---', 2)[2]
        
            title = get_title_from_metadata(metadata_content)
            if title:
                return title
        title = get_title_from_heading(content)
        if title:
            return title
    return os.path.splitext(os.path.basename(file_path))[0]
```

テストを再実行し、問題が解決したことを確認します。

エラーが解消したので、スクリプトを実行してみます。

```console
$ python traverse.py article result.csv
$ cat result.csv
cat result.csv      
relative path,filename,title
post.md,post.md,GitHub CopilotのEdit with Copilotを使ってみよう
old/whi-advent-2022.md,whi-advent-2022.md,はじめに
old/whi-advent-2020.md,whi-advent-2020.md,あらすじ
```

期待通りに動作していることが確認できました。

## 使い方のコツ

### Working Setに必要なファイルを追加する

Copilot Edit機能のWorking Setにファイルを追加する際には、スクリプトファイルだけでなく、APIの仕様書や似たようなスクリプトなどを追加すると、生成されるコードがより適切になります。コーディングガイドラインをマークダウン形式でまとめておき、参照させるのも良いでしょう。

### 編集するファイルを小さくする

Copilot Edit機能でファイルを編集する場合は、ファイル全体を生成するような挙動になります。長いファイルの編集は時間がかかり、失敗することも多いので、ファイルの行数は200行程度までが適切です。それ以上の場合は、関数やクラスを分割して、複数のファイルに分割することを検討してください。

なお参照するファイルについては特に長さ制限はないようなので、気にする必要はありません。

### プロンプトでどのファイルを修正するか指定する

Copilot Edit機能ではWorking Setにあるファイルの中から編集すべきファイルを見つけますが、曖昧な指示の場合、ユーザの意図と異なるファイルを修正することがあります。そのため、プロンプトで明確にどのファイルを修正するか指定することが重要です。


## まとめ

このように、Edit with Copilot機能を使うことで、作業スクリプトの作成やテストコードの作成を効率的に行うことができます。コメントを書いて仕様を明確にしておくことで、生成されるコードが期待通りになりますし、コードの保守性も高くなります。ぜひ、Edit with Copilot機能を使って、作業効率を向上させてみてください。