DevOps システム開発

GitHub Action + SlackでIssueの締切を管理する!

サンエル開発部

こんにちは!開発部の藤川です。
日々IssueのDueDateと格闘してます

はじめに

プロジェクト管理において、タスクの締切を設定しその通りに実行していくことは重要です。

そのため、サンエルではGitHubのプロジェクト機能で、Issueに対してDueDate(締切)を設定するようにしています。ただし、「設定はしているが気づいたらDueDateが切れてた」等適切に管理できていないことが課題でした。

毎日プロジェクトマネージャーが担当プロジェクトにDueDateの切れたIssueがないかを探して管理しようとしていましたが、明確なルールがある作業はできるだけシステムに任せたいところです。

そこで発見したのがこのサイト

GitHubだけでPJ管理を頑張ってみる兄貴 Part1 〜GithubでWBS作ったら、リマインドまで自動化できた話〜
(余談ですが、今回参考にしたサイトの フェズ様 は、サンエルとも以前から関係のある会社です。いつもお世話になっております!)

こちらを参考にしてGithubAction&Slack Appを利用したらいい感じにできるのでは?

ということで、この記事ではGitHub Actions&Slack Appを利用して、Issueの締切を通知するツールを作ります!

想定読者

  • プロジェクトマネージャー
  • IssueのDueDate管理をしたい人

要件

  • 毎朝Slackに通知
  • プロジェクトごとのSlackチャンネルに通知できる
  • プロジェクトマネージャーにメンションできる
  • DueDateを過ぎているIssueのリンクを貼る
  • 明日がDueDateのIssueのリンクを貼る

実際にできたものがこちら

これがSlackのチャンネルに投稿されます。

黄色のバーがもうすぐDueDateが切れるIssue、赤のバーがすでにDueDateが切れているIssueです。

通知対象がない場合は『通知はありません』を投稿します。

アイコンはChatGPTに描いてもらった羊の執事です。可愛いものに褒められると嬉しい

作るものの全体像の説明

登場人物はGitHub Actions, Python, GitHub API, Slack Appの4人。

  1. GitHub Actionsを使って、Pythonのスクリプトを定期的に実行する。
  2. スクリプト内で以下を行う。
    1. GitHub API GraphQLを叩いて、Issueの情報を取得する。
    2. IssueのDueDateが切れているか判定する。
    3. Slack AppからWebhookを利用して通知を投げる。

Slack にはAppを自前で作らずにIncoming Webhookが使えるようにするアプリが公式で用意されています。ただし公式ではAppを作ることが推奨されているのでAppを作ります。

https://test-ot67087.slack.com/marketplace/A0F7XDUAZ--incoming-webhook-

これはレガシーなカスタム統合で、チームがSlackと統合するための時代遅れの方法です。 これらの統合には新しい機能がないため、将来的に非推奨となり、削除される可能性があります。 私たちはこれらの使用をお勧めしません。 代わりに、その代わりとなるものをチェックすることをお勧めします: Slackアプリです。

また上記のフローを複数のプロジェクトで使えるように、プロジェクト外のリポジトリにreusableな形で作ります。

コードの解説

ORG/REUSED_REPO/.github/workflows/remind_issues.yml (reuse 元)

環境構築して pythonのスクリプトを実行するだけです。

name: Remind issues

on:
  workflow_call:
    inputs:
      ENABLE:
        description: 'Set to true to run the job'
        required: true
        type: boolean
        default: true
      SLACK_USER_ID:
        required: true
        type: string
    secrets:
      ORG_TOKEN:
        required: true
      SLACK_WEBHOOK_URL:
        required: true

jobs:
  remind_issues:
    if: ${{ inputs.ENABLE }}
    runs-on: ubuntu-latest
    steps:
      - name: Check out repo
        uses: actions/checkout@v4
        with:
          repository: ORG/REUSED_REPO
          token: ${{ secrets.ORG_TOKEN }}

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.x"

      - name: Install dependencies
        run: pip install requests

      - name: Run alert script
        env:
          SLACK_USER_ID: ${{ inputs.SLACK_USER_ID }}
          GITHUB_TOKEN: ${{ secrets.ORG_TOKEN }}
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
          OWNER: ${{ github.repository_owner }}
          REPO_NAME: ${{ github.event.repository.name }}
        run: python issue_due_reminder.py
YAML

ポイント

  • リポジトリでreuseを許可する設定が必要です
  • 下記の変数をreuseする先のリポジトリで設定します
    • inputs
      • ENABLE:使わなくなったリポジトリで通知を切りたい時にここをfalseにします。デフォルトtrue
      • SLACK_USER_ID:メンションしたい人のSlack ID。Slack IDの取得方法はこちら
    • secrets
      • ORG_TOKEN:GitHub APIの認証通す用のトークン。サンエルはorganization共通でトークンを使えるようにしてます。
      • SLACK_WEBHOOK_URL:SLACK APPのWEBHOOK URL。

SLACKのアプリの作成方法とWEBHOOK_URLの取得方法はこちら

ORG/REUSED_REPO/issue_due_reminder.py

実行されるpythonスクリプト

import datetime
import os
import requests
from zoneinfo import ZoneInfo

GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN")
SLACK_WEBHOOK_URL = os.environ.get("SLACK_WEBHOOK_URL")
OWNER = os.environ.get("OWNER")
REPO_NAME = os.environ.get("REPO_NAME")
SLACK_USER_ID = os.environ.get("SLACK_USER_ID")

TIMEZONE = "Asia/Tokyo"
GITHUB_GRAPHQL_URL = "<https://api.github.com/graphql>"
GITHUB_QUERY = """
    query($owner: String!, $repo_name: String!) {
       repository(owner: $owner, name: $repo_name) {
         issues(first: 100, states: OPEN, orderBy: { field: CREATED_AT, direction: DESC }) {
          edges {
            node {
              title
              url
              assignees(first: 10) {
                nodes {
                  login
                }
              }
              projectItems(first: 10) {
                nodes {
                  fieldValueByName(name: "DueDate") {
                    ... on ProjectV2ItemFieldDateValue {
                      date
                    }
                  }
                }
              }
            }
          }
        }
      }
    }
"""

def fetch_issues():
    headers = {"Authorization": f"Bearer {GITHUB_TOKEN}"}
    variables = {"owner": OWNER, "repo_name": REPO_NAME}

    response = requests.post(
        GITHUB_GRAPHQL_URL,
        json={"query": GITHUB_QUERY, "variables": variables},
        headers=headers
    )
    if response.status_code != 200:
        msg = f"Query failed with status code {response.status_code}: {response.text}"
        raise RuntimeError(msg)

    result = response.json()
    if "errors" in result:
        raise ValueError(f"GraphQL Errors: {result['errors']}")

    edges = (
        result.get("data", {})
              .get("repository", {})
              .get("issues", {})
              .get("edges", {})
    )

    return edges

def parse_issues(issue_edges):
    issues = []
    for edge in issue_edges:
        issue = edge["node"]
        title = issue["title"]
        url = issue["url"]
        assignees = [a["login"] for a in issue["assignees"]["nodes"]]

        project_items = issue["projectItems"]["nodes"]
        if len(project_items) > 0:
            field = project_items[0]["fieldValueByName"]
            due_date = field["date"] if field else None
        else:
            due_date = None

        issues.append({
            "title": title,
            "url": url,
            "assignees": assignees,
            "due_date": due_date
        })

    return issues

def categorize_issues(issues):
    tomorrow_issues = []
    overdue_issues = []
    tz = ZoneInfo(TIMEZONE)
    today = datetime.datetime.now().astimezone(tz).date()
    tomorrow = today + datetime.timedelta(days=1)

    for issue in issues:
        due_str = issue.get("due_date")
        if due_str is None:
            continue

        due_date = datetime.datetime.strptime(due_str, "%Y-%m-%d").date()

        if due_date == tomorrow:
            tomorrow_issues.append(issue)
        if due_date < today:
            overdue_issues.append(issue)

    return tomorrow_issues, overdue_issues

def create_attachments(color, title, issues):
    attachments = []
    attachments.append({
        "text": title,
        "mrkdwn_in": ["text"]
    })

    for issue in issues:
        attachments.append({
            "color": color,
            "title": issue["title"],
            "title_link": issue["url"],
            "text": f"Assignees: {', '.join(issue['assignees'])}\\nDueDate: {issue['due_date']}",
            "short": False
        })

    return attachments

def notify_slack(tomorrow_issues, overdue_issues):
    attachments = []

    if tomorrow_issues:
        color = "warning"
        title = ":rotating_light: *明日がDueDateのIssue* :rotating_light:"
        attachments.extend(create_attachments(color, title, tomorrow_issues))

    if overdue_issues:
        color = "danger"
        title = ":collision: *DueDate切れのIssue* :collision:"
        attachments.extend(create_attachments(color, title, overdue_issues))

    if attachments:
        text = f"<@{SLACK_USER_ID}>\\nIssueの締め切り通知です"
    else:
        text = ":tada: 通知が必要なIssueはありません :tada:"

    slack_message = {
        "text": text,
        "attachments": attachments
    }

    resp = requests.post(SLACK_WEBHOOK_URL, json=slack_message)
    if resp.status_code != 200:
        msg = f"Failed to post to Slack: {resp.text}"
        raise RuntimeError(msg)

def main():
    issue_edges = fetch_issues()
    issues = parse_issues(issue_edges)
    tomorrow_issues, overdue_issues = categorize_issues(issues)
    notify_slack(tomorrow_issues, overdue_issues)

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

色々やってます。大体の流れは以下。

  1. GitHub GraphQLでIssueの情報を取得する(fetch_issues())
  2. GraphQLのレスポンスのままだと使いにくいので、必要な情報を取り出したりして整形する(parse_issues())
  3. DueDateごとにIssueを分類する(categorize_issues())
  4. Slackに通知を投げる(notify_slack())

1 GitHub GraphQLでIssueの情報を取得する:fetch_issues

GitHub GraphQL で取得できるIssueは制限がかかっており、 最新 100 件のOpen Issueのみ取得できます。
必要ならカーソルループで全件取得可能ですが、まずは100件で運用してみています。
https://docs.github.com/ja/enterprise-server@3.10/graphql/overview/rate-limits-and-node-limits-for-the-graphql-api#node-limit

取得する数は一応200くらいにしとくか〜と適当に決めたら、以下のエラーが返ってきました。

Requesting 200 records on the issues connection exceeds the first limit of 100 records.

2 GraphQLのレスポンスのままだと使いにくいので、必要な情報を取り出したりして整形する:parse_issues

GraphQLはエッジという単位でデータを返してくるため、そこから必要なデータを取り出します。

エッジ等の考え方はこのページが詳しいです。

ポイント

  • Edge:CursorとNodeのまとまり
    • GraphにおけるNodeとNodeを繋ぐ線であるEdgeとは別物(紛らわし!!)
  • Cursor:「取得対象となった一覧リストの中で、そのオブジェクトがどの位置にあるものなのか」という位置情報を指し示すもの
    • リストのどこからどこまでの情報が欲しいみたいに位置を指定したいとき使う

3 DueDateごとにIssueを分類する:categorize_issues

締切をすでに過ぎているものともうすぐ締め切りのものを可視化したかったため、DueDateがすでに切れているISSUEと、切れそうなISSUEを通知することにしました。

DueDateが実行日より前のものを切れているISSUE、DueDateが実行日の次の日のものを切れそうなISSUEとして分類します。

日付で分割しているだけで特に複雑なことはしていません

4 Slackに通知を投げる:notify_slack

DueDateごとに分類したIssueを、Slackに通知できる形に整形しwebhookにpostします。
attachmentとしてissueのタイトルやリンクを追加する形です。

もうすぐ締め切りのISSUEは黄色、締切を過ぎたISSUEは赤のバーを表示してわかりやすくします。

絵文字をつけると楽しい。

ORG/REPO/.github/workflows/remind_issues.yml (リマインドしたいリポジトリ内)

実際にDueDateを管理したいリポジトリで先ほどのActionsをreuseします。

name: Call issues due date alert

on:
  schedule:
    # 0:00 utc is 9:00 jst
    - cron: "55 23 * * 0-4"
  workflow_dispatch:

jobs:
  reuse_issue_due_reminder:
    uses: ORG/REUSED_REPO/.github/workflows/remind_issues.yml@main
    with:
      SLACK_USER_ID: ${{ vars.SLACK_USER_ID }}
      ENABLE: ${{ vars.ENABLE == 'true' }}
    secrets:
      ORG_TOKEN: ${{ secrets.ORG_TOKEN }}
      SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
YAML

ポイント

  • リポジトリでreuseできるようにする設定が必要です。
  • schedule は UTC なので、JST 9:00 にしたい場合は "0 0 * * *" を指定。
    • ただし実運用では、朝のジョブ集中で実行遅延が発生しやすいため、JST 8:55(UTC 23:55)あたりに変更すると安定します。
  • workflow_dispatchも設定しておくと、手動で実行してテストできるので便利です。

まとめ

GitHub Actions+Python+Slack App の組み合わせで、Project V2 の DueDate を使った締切管理をフル自動化しました。

GraphQLの扱いに少しクセがありますが、あとはオーソドックスなActionsの使い方で実装できます。

あとはやるとしたら

  • Slack上でStatusとかDueDateを変更できる
  • 通知の時にIssue完了してたら褒めてくれる

みたいなことやっても楽しいかもしれません。

ではみなさん確認を自動化してよいIssue管理ライフを!

-DevOps, システム開発
-, ,