Feedforce Developer Blog

フィードフォース開発者ブログ

GitHub の issue をまるっと複製する GitHub Action「Issue Duplicator」を自作した

こんにちは、ソーシャルPLUS でフロントエンド開発をしている id:mashabow です。

ここ最近「issue の複製めんどくさいなー。もっと楽にできればいいのに」と思うことが多かったので、issue をまるっと複製してくれる GitHub Action「Issue Duplicator」を個人で作ってみました。というわけで、今回はこの Issue Duplicator の紹介です。

github.com

github.com

ちなみにこの記事は、Feedforce Group Advent Calendar 2022の 4 日目の記事です。3 日目は、かくさんの『セロペギアボッセリに学ぶ、鈴木さんとの接し方』でした。子育て 1 年目な自分にとっては「変数多すぎな。」がとても印象的でした。わかる。(そういう話じゃない?)

Issue Duplicator でできること

複製したい issue に /duplicate とだけ書いたコメントをつけると、

その issue をまるっと複製した新しい issue を、同じリポジトリに作ってくれます。

GitHub projects のカスタムフィールドも複製するので、project 上で見るとこんな感じ。

複製される項目の詳細は、以下のとおりです。

  • Title(issue のタイトル)
  • Body(issue の本文)1
  • Assignees
  • Labels
  • Milestone
  • その issue が紐付けられている GitHub projects
    • project のカスタムフィールドの値も、すべてコピーされます

なお、以下の項目は複製されません。

  • issue についているコメント
  • Author(アクションに設定する personal access token を発行したユーザーが Author になります)
  • issue が open か closed か(常に open で作成されます)
  • issue がロックされているか否か(非ロック状態で作成されます)

Issue Duplicator の使い方

Issue Duplicator を動かしたいリポジトリに、以下のワークフローを作成します。

# .github/workflows/issue-duplicator.yml

name: Issue Duplicator

on:
  issue_comment:
    types: [created, edited]

jobs:
  run:
    runs-on: ubuntu-latest
    steps:
      - uses: mashabow/issue-duplicator-action@v1
        with:
          github-token: ${{ secrets.ISSUE_DUPLICATOR_PAT }}

次に、repo スコープと project スコープを持つ personal access token を発行しましょう。そして、その personal access token を、ISSUE_DUPLICATOR_PAT という名前でリポジトリのシークレットに登録すれば準備完了です。

適当な issue に /duplicate というコメントを付けて、ちゃんと複製されるか確認しましょう! 複製に成功すると、新しい issue へのリンクが追加されます。

ログを確認したい場合は、リポジトリの Actions タブから。

Issue Duplicator を作った経緯

わたしの所属するソーシャル PLUS 開発チームでは、メンバーが増えてきたこともあって先月から大規模スクラム Large-Scale Scrum(LeSS) を導入しています。これを機に、今までのスクラムのやり方を見直し、改善することになりました。プロダクトバックログはもともと issue と GitHub projects で管理しており、それは引き続き使っていくんですが、

  • 1 スプリントでプロダクトバックログアイテムを 4 つほど消化できるように、アイテムの粒度をもっと細かくしたい
  • リファインメントやプランニングの場で、どんどんプロダクトバックログアイテムを分割したい

というのが変化点でした。LeSS を導入するにあたって『大規模スクラム Large-Scale Scrum(LeSS)―アジャイルとスクラムを大規模に実装する方法]』という本を読んだんですが、プロダクトバックログアイテムの分割については、9 章と 11 章で 20 ページほどを割いて、詳しく解説されています。

さて、このプロダクトバックログアイテム(1つのアイテムを1つの issue として管理しています)をいざ分割しようとすると、次の手順を踏むことになります。

  1. 分割元の issue とは別に、新しい issue を作る
  2. 元の issue の本文を新しい issue の本文にコピペして、適宜編集する
  3. 新しい issue にラベルやマイルストーンをつける
  4. 新しい issue をプロダクトバックログの project に追加する
  5. 新しい issue の、project のカスタムフィールド(優先度・ポイント・担当チームなど)を埋める

……面倒ですよね。実際に何度か分割してみましたが、やっぱり面倒です。

分割元の issue と、新しく作った issue とでは、ラベル・マイルストーン・Project のカスタムフィールドなどはだいたいが共通になるでしょう。もちろん、分割して別々のアイテムになったわけですから、細部は異なりますが。というわけで、「issue をまるっと複製してくれる便利ツールがあれば助かるのでは…?」と考えました。とりあえず複製して、変えたい箇所は手で編集すればいいか、と。

調べてみたら Duplicate Issue という GitHub Action がありましたが、残念ながら GitHub projects には対応していません。じゃあいい機会だし作ってみるか、と自作することにしました。

アクションを自作する

アクションの作り方には何種類かありますが、普段 TypeScript を書いている身にとっては、JavaScript アクションというものを開発するのがとっつきやすそうでした。

TypeScript で開発するためのテンプレートが公式で用意されていたので、今回はこちらを使ってみました。が、README に詳しいことが書かれていなかったり、ビルド済みのファイルをコミットする必要があったり、使い勝手は正直微妙です。

github.com

スクリプトの書き方はいたってシンプルで、入出力に @actions/core の関数を使う以外は、基本的には普通の Node.js のスクリプトと同じです。詳しい書き方については、公式のドキュメントもネット上の日本語記事も充実しているので、ここでは割愛します。

今回は issue の複製をしたいので、GitHub の Web API を利用します。@actions/github を使うと、Octokit で簡単に API を叩くことができます。

// Octokit を使って issue を複製する

import * as github from "@actions/github";

const octokit = github.getOctokit(token);

// https://docs.github.com/en/rest/issues/issues#create-an-issue
const { data: createdIssue } = await octokit.rest.issues.create({
  owner: repository.owner.login,
  repo: repository.name,
  title: originalIssue.title,
  body: originalIssue.body ?? undefined,
  milestone: originalIssue.milestone?.number,
  labels: originalIssue.labels,
  assignees: originalIssue.assignees.map(({ login }) => login),
});

次に、新しく作った issue を GitHub project に追加したいわけですが、project 関連の API は REST では提供されていません。そのため、GraphQL の方を使う必要があります。オブジェクトの一覧を見ると、ProjectProjectV2ProjectNext と似たようなオブジェクトが 3 つもありますが、今回使うのは ProjectV2 です。ややこしいですね。

  • Project: 古いバージョンの project (classic)
  • ProjectV2: 新しい高機能な project
  • ProjectNext: ProjectV2 の以前の名前。廃止予定

また、ProjectV2 の中のそれぞれのアイテム(issue・PR・ドラフト)は、ProjectV2Item という名前になっています。以下は Octokit で GraphQL API を叩く例です。

// issue を project に追加する
const data = await octokit.graphql(
  `
  mutation addIssueToProject($input: AddProjectV2ItemByIdInput!) {
    addProjectV2ItemById(input: $input) {
      item {
        id
      }
    }
  }
`,
  { input: { projectId, contentId: issueId } }
);

特にハマりどころもなく素直に使えますが、残念なことに、そのままでは引数や戻り値の型がつきません。

octokit.graphql に型をつける

せっかく TypeScript で開発しているんですから、やっぱり型が欲しい。ということで、GraphQL Code Generator を使って型をつけてみました2

GraphQL Code Generator を以下のように設定して、

// codegen.ts

import type {CodegenConfig} from '@graphql-codegen/cli'

const config: CodegenConfig = {
  overwrite: true,
  schema: 'https://docs.github.com/public/schema.docs.graphql',
  documents: 'src/**/*.graphql',
  generates: {
    'src/graphql/index.ts': {
      plugins: [
        'typescript',
        'typescript-operations', // 各 operation から型を生成する
        'typescript-generic-sdk' // SDK(各 operation を実行する関数群)を生成する
      ],
      config: {
        documentMode: 'string' // operation の中身を文字列としてリクエスト関数に渡す
      }
    }
  }
}

export default config

GraphQL の operation のファイルを用意します。

# src/graphql/addIssueToProject.graphql

mutation addIssueToProject($input: AddProjectV2ItemByIdInput!) {
  addProjectV2ItemById(input: $input) {
    item {
      id
    }
  }
}

そして GraphQL Code Generator を実行。

$ yarn graphql-codegen --config codegen.ts

すると、以下のようなファイルが生成されます。

// src/graphql/index.ts

// この上にはスキーマから生成された型定義がひたすら書かれている(省略)

// operation の変数の型
export type AddIssueToProjectMutationVariables = Exact<{
  input: AddProjectV2ItemByIdInput;
}>;

// operation の結果の型
export type AddIssueToProjectMutation = { __typename?: 'Mutation', addProjectV2ItemById?: { __typename?: 'AddProjectV2ItemByIdPayload', item?: { __typename?: 'ProjectV2Item', id: string } | null } | null };

// operation の中身そのもの
export const AddIssueToProjectDocument = `
    mutation addIssueToProject($input: AddProjectV2ItemByIdInput!) {
  addProjectV2ItemById(input: $input) {
    item {
      id
    }
  }
}
    `;

// リクエスト関数の型
export type Requester<C = {}, E = unknown> = <R, V>(doc: string, vars?: V, options?: C) => Promise<R> | AsyncIterable<R>
// リクエスト関数から SDK(各 operation を実行する関数群)を作る関数
export function getSdk<C, E>(requester: Requester<C, E>) {
  return {
    addIssueToProject(variables: AddIssueToProjectMutationVariables, options?: C): Promise<AddIssueToProjectMutation> {
      return requester<AddIssueToProjectMutation, AddIssueToProjectMutationVariables>(AddIssueToProjectDocument, variables, options) as Promise<AddIssueToProjectMutation>;
    }
    // この例では operation が1つだけだが、複数の operation があれば、関数がここに並ぶ
  };
}
// SDK の型
export type Sdk = ReturnType<typeof getSdk>;

これだけだとどう使うのかわかりにくいかもしれませんが、以下の使用例を見ればイメージが付くんじゃないでしょうか。

import { getSdk } from "./graphql";

// octokit.graphql をリクエスト関数として利用する SDK を作る
const sdk = getSdk(octokit.graphql);

// addIssueToProject を実行
const data = await sdk.addIssueToProject({
  input: { projectId, contentId: issueId },
});

もちろん、sdk.addIssueToProject の引数と戻り値にはちゃんと型が付きます!

アクションを公開する

今回は TypeScript で開発したので、公開する前にビルド(JavaScript へのトランスパイル)を行う必要があります。公式ドキュメントで紹介されていた JasonEtco/build-and-tag-action を使ってみましたが、以下のような少し癖のある動作をします。

  1. GitHub の Web UI から、ビルドしたいコミットを指定してリリースを作成する
  2. リリースが作成されると、JasonEtco/build-and-tag-action によって以下の処理が実行される
    1. そのコミットのソースコードをビルドする
    2. ビルドされた dist/index.jsaction.yml だけ のコミットを、何のブランチにも属していない状態で新しく作る
    3. 1 で作成したリリースを、その新しいコミットで置き換える
    4. 1 で作成したリリースのバージョンに応じて、v1.2.3, v1.2, v1 などのタグを打つ(もしくは移動させる)

これがドキュメントからは読み取りづらく、最初戸惑いました。ビルド後の新しいコミットに README.md が入っていないのも、いまいちな点です。

作成したアクションは、審査不要で GitHub Marketplace に掲載することができます。こちらについては、ドキュメントと UI 上の案内に従っていけば、とても簡単に公開できました。

おわりに

というわけで、Feedforce Group Advent Calendar 2022の 4 日目は Issue Duplicator を自作した話でした。

github.com

書いてから気づいたんですが、こんな真面目な記事じゃなくて、3 月に生まれた息子のかわいさをひたすら語る記事にすればよかったですね。

明日はサラリーマン会計士のあの方が、テントサウナについて語ってくださるそうです。楽しみですね!

note.com


  1. 社内では、issue の本文をなぜか「0 コメ」と呼んでいます。ローカル用語だと思いますが、ニコニコ動画全盛期にできた呼び方なんでしょうか…?
  2. octokit.graphql に型をつける方法、需要は多いはずなんですが、調べ方が悪かったのか検索してもあまり出てきませんでした。定番の方法をご存じの方がいればぜひご教示ください。