Sync Issues to Project #10
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # tarosky/workflows 自身で動く cron ワークフロー(workflow_call ではない)。 | |
| # 指定 Topic を持つ公開済みプラグインリポジトリの Open Issue を横断的に集め、 | |
| # まだどの Project (V2) にも登録されていない Issue だけを Organization Project に追加する。 | |
| # | |
| # GitHub 組み込みの Auto-add は「1 ワークフロー = 1 リポジトリ」の制約があるため、 | |
| # org 全体を巡回する独自 cron として実装している。 | |
| # | |
| # 事前準備(リポジトリ Secrets / Variables): | |
| # - Secret PROJECT_SYNC_PAT : Fine-grained PAT | |
| # Resource owner: tarosky / Repository access: All repositories | |
| # Repository permissions: Metadata=Read, Issues=Read | |
| # Organization permissions: Projects=Read and write | |
| # - Variable PROJECT_ID : 追加先 Organization Project (V2) の Node ID | |
| # 例) gh project list --owner tarosky --format json で取得 | |
| name: Sync Issues to Project | |
| on: | |
| schedule: | |
| - cron: '0 1 * * *' # 毎日 01:00 UTC(= 10:00 JST) | |
| workflow_dispatch: | |
| # 同時実行を抑止し、cron と手動実行が重なっても二重巡回しないようにする。 | |
| concurrency: | |
| group: sync-issues-to-project | |
| cancel-in-progress: false | |
| jobs: | |
| sync: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Sync issues to organization project | |
| uses: actions/github-script@v8 | |
| with: | |
| github-token: ${{ secrets.PROJECT_SYNC_PAT }} | |
| script: | | |
| const projectId = process.env.PROJECT_ID; | |
| if (!projectId) { | |
| core.setFailed('PROJECT_ID variable is not set.'); | |
| return; | |
| } | |
| // 公開済みプラグインを示す Topic。AND ではなく OR で集めたいので | |
| // Topic ごとに検索してリポジトリを id でユニーク化する。 | |
| const topics = ['wordpress-plugin-published', 'wordpress-plugin-ga']; | |
| // 1. 対象 Topic を持つリポジトリをユニークに集める(100 件超に備えてページング) | |
| const repos = new Map(); | |
| for (const topic of topics) { | |
| let cursor = null; | |
| while (true) { | |
| const { search } = await github.graphql(` | |
| query($q: String!, $cursor: String) { | |
| search(query: $q, type: REPOSITORY, first: 100, after: $cursor) { | |
| pageInfo { hasNextPage endCursor } | |
| nodes { ... on Repository { id nameWithOwner } } | |
| } | |
| } | |
| `, { q: `org:tarosky topic:${topic}`, cursor }); | |
| for (const r of search.nodes) repos.set(r.id, r); | |
| if (!search.pageInfo.hasNextPage) break; | |
| cursor = search.pageInfo.endCursor; | |
| } | |
| } | |
| console.log(`Target repositories: ${repos.size}`); | |
| // 2. 各リポジトリの Open Issue をページングしながら取得・登録 | |
| let added = 0, skipped = 0; | |
| for (const repo of repos.values()) { | |
| const [owner, name] = repo.nameWithOwner.split('/'); | |
| let cursor = null; | |
| while (true) { | |
| const { repository } = await github.graphql(` | |
| query($owner: String!, $name: String!, $cursor: String) { | |
| repository(owner: $owner, name: $name) { | |
| issues(states: OPEN, first: 100, after: $cursor) { | |
| pageInfo { hasNextPage endCursor } | |
| nodes { | |
| id | |
| number | |
| projectItems(first: 1) { totalCount } | |
| } | |
| } | |
| } | |
| } | |
| `, { owner, name, cursor }); | |
| for (const issue of repository.issues.nodes) { | |
| // 何らかの Project (V2) に登録済みならスキップ。 | |
| // → 初回は全 Open Issue を一括登録、以降は新規 Issue だけが対象になる。 | |
| // → 手動で Project から外せば次回再登録される(取りこぼし防止)。 | |
| if (issue.projectItems.totalCount > 0) { | |
| skipped++; | |
| continue; | |
| } | |
| // addProjectV2ItemById は冪等なので競合状態でも重複登録されない。 | |
| await github.graphql(` | |
| mutation($projectId: ID!, $contentId: ID!) { | |
| addProjectV2ItemById(input: { projectId: $projectId, contentId: $contentId }) { item { id } } | |
| } | |
| `, { projectId, contentId: issue.id }); | |
| console.log(`Added: ${repo.nameWithOwner}#${issue.number}`); | |
| added++; | |
| } | |
| if (!repository.issues.pageInfo.hasNextPage) break; | |
| cursor = repository.issues.pageInfo.endCursor; | |
| } | |
| } | |
| console.log(`Done. added=${added}, skipped=${skipped}`); | |
| env: | |
| PROJECT_ID: ${{ vars.PROJECT_ID }} |