让我们在 GitHub Projects 通过 Actions 来管理投入开发环境的分支

你好。

我是 @ingen084,是(ニコニコ)的プレミアム課金開発チーム的成员。

过去几年,我们一直在讨论改善和重构的话题,但今年的话题与之不太相关。

目前我们团队正在重新审视CI/CD工具等,并考虑将Jenkins一段时间以来进行过转换,转移到GitHub Actions上。
所以我想到了一种稍微不同的方法来部署开发环境,并试着挑战它。

我尚未进行正式运营,此篇文章只是我尝试实施的记录,请谅解。

已完成的东西 (yǐ de

image.png

非常抱歉,由于实际环境的屏幕截图模糊不清。 创建PullRequest后,其相应的卡片会自动添加。根据情况,将该卡片放入”dev投入対象”和”dev優先投入対象”栏目中,该PR将自动合并为用于部署到开发环境的分支,并被投入到开发环境中。由于我们还没有建立移动条目的钩子机制,因此部署的开始仍需手动进行,但我们计划在考虑使用该机制的同时进行调整以提高使用体验。

经过

我们团队目前只在一个(完全可操作的)开发环境中运作。这主要是由于与支付代理公司等的连接原因。

以前,我们在执行 Jenkins 作业时通过用逗号分隔的字符串发送要合并的目标分支。(即使如此,以前还没有合并机制,我们不得不创建用于部署的分支,所以这是一个很大的改进。)

image.png

课题

然而,随着新功能的添加、更新和重构等活动的活跃,开发环境中需要经常保留的分支数量增加了起来,导致每次部署时需要繁琐地确定并复制应该添加的分支。有时候,由于复制的某个分支已经合并并且不存在了,这可能会导致错误的发生。

如果团队规模更大,可以考虑采用产品採用或自制工具等方法,但由于还不是那么大规模,我正在寻找一种不需付出过多成本却能够简洁明了的解决方法。

GitHub项目

在GitHub上有一个名为Projects的功能,可以通过面板来管理Issue、PullRequest等。由于项目是按照组织或用户进行管理的,所以可以将多个仓库的PR放入其中。

用这个

    • PR の作成に合わせ自動登録

マージやクローズに合わせて自動削除

投入のためのラベルがついたカンバンボード上で PR のカードを移動
状況に合わせてビルド時にマージ

我认为以这样的方式,可以更容易地确定哪些公关活动可以无实际额外成本地投入。

执行

根据 PR 的状态,自动将卡片添加到项目中

参考にすると、プルリクエストが作成されたときにカードを作成し、閉じられたときにカードをアーカイブするように設定を追加します。

2bac98cdb6b5dbed4590d3ec2005d5b9.png

然而,由于在创建时需要按照存储库的单位进行卡片添加,因此如果所涉及的存储库数量较多,根据不同的方案可能会受到限制。
因此,这次我们还允许从 Actions 的工作流中添加卡片。
但是,请不要误解,我并不是说不知道这可以自动化!(颤抖的声音)

生成GraphQL API的代码

操作项目是通过 GraphQL API 进行的。
由于在 Actions 的工作流中直接编写复杂操作非常困难,所以我们决定用 TypeScript 创建操作本身。
为了进行 GraphQL 代码生成,我们还引入了 @graphql-codegen,基于 https://github.com/actions/typescript-action 的代码。

本次使用的 GraphQL 查询和变异。

query getProjectId($org: String!, $number: Int!) {
  organization(login: $org){
    projectV2(number: $number) {
      id
    }
  }
}
mutation addCard($project:ID!, $pr:ID!) {
  addProjectV2ItemById(input: {projectId: $project, contentId: $pr}) {
    item {
      id
    }
  }
}
mutation deleteCard($projectId: ID!, $itemId: ID!) {
  deleteProjectV2Item(input: {projectId: $projectId, itemId: $itemId}) {
    deletedItemId
  }
}
query getCards($org: String!, $number: Int!) {
  organization(login: $org) {
    projectV2(number: $number) {
      id
      items(first: 100) {
        nodes {
          id, type, fieldValueByName(name: "Status") {
            __typename
            ... on ProjectV2ItemFieldSingleSelectValue {
              name
            }
          },
          content {
            __typename
            ... on PullRequest {
              headRepository {
                nameWithOwner
              },
              number,
              headRefName
            }
          }
        }
      }
    }
  }
}

以下是四个选项:
随便设置

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

const config: CodegenConfig = {
  overwrite: true,
  schema: "https://docs.github.com/public/schema.docs.graphql",
  documents: "graphql/**/*.graphql",
  generates: {
    "src/generated/graphql.ts": {
      plugins: [
        "typescript",
        "typescript-operations",
        "typescript-graphql-request",
      ],
      config: {
        enumsAsTypes: true,
        avoidOptionals: true,
      },
    },
  },
}

export default config

生成代码。

graphql-codegen --config src/codegen.ts

项目的操作

暂时的目标是快速粗略地创建,以便在PR的开启和关闭时能够进行添加和删除操作,其他情况下则获取可合并的分支。

// パラメータの取得
const token = core.getInput("token", { required: true })
const organization = core.getInput("organization", { required: true })
const projectNumber = Number(core.getInput("project-number", { required: true }))

// GraphQL クライアントの作成
const graphQLClient = new GraphQLClient("https://api.github.com/graphql", {
  headers: {
    Authorization: `Bearer ${token}`,
  },
})
const qlClient = getSdk(graphQLClient)

// PR の場合はイベント同期
if (github.context.eventName == "pull_request") {
  if (!github.context.payload.pull_request) return
  // プロジェクトIDを取得
  const fields = await qlClient.getProjectId({org: organization, number: projectNumber})

  switch (github.context.payload.action) {
    // 開かれたときは projects に追加
    case "opened":
    case "reopened":
      qlClient.addCard({project: fields.organization?.projectV2?.id ?? "", pr: github.context.payload.pull_request.node_id});
      break;
    
    // 閉じられたときは削除
    case "closed":
      // カードをリストアップしてイベントを発生させた PullRequest の物をリストアップ
      const cards = (await qlClient.getCards({org: organization, number: projectNumber}))
        .organization?.projectV2?.items.nodes?.filter(n =>
          n?.type == "PULL_REQUEST" && n.content?.__typename == "PullRequest" &&
          n.content.number == github.context.payload.pull_request?.number && n.content.headRepository?.nameWithOwner == github.context.payload.repository?.full_name
        )
      // すべて削除
      await Promise.all(cards?.map(async c => {
        await qlClient.deleteCard({ projectId: fields.organization?.projectV2?.id ?? "", itemId: c?.id ?? "" })
      }) ?? []);
      break;
  }
  return
}

// それ以外の時にはブランチ名収集モード
const targetRepositoryName = core.getInput("target-repository-name", { required: true })
const priorityTargetStatusName = core.getInput("priority-target-status-name", { required: true })
const targetStatusName = core.getInput("target-status-name", { required: true })

// パラメータに記載されているリポジトリの PR のカードを取得
// (雑実装のためカードが100件以上になると全部取得できなくなる、、、)
const cards = (await qlClient.getCards({org: organization, number: projectNumber}))
.organization?.projectV2?.items.nodes?.filter(n =>
  n?.type == "PULL_REQUEST" && n.content?.__typename == "PullRequest" &&
  n.content.headRepository?.nameWithOwner == targetRepositoryName
)

// 優先投入対象のカードを確認
const priorityBranches = cards
  ?.map(n => (n?.fieldValueByName?.__typename == "ProjectV2ItemFieldSingleSelectValue" && n.fieldValueByName.name == priorityTargetStatusName && n.content?.__typename == "PullRequest") ? n.content.headRefName : null)
  .filter(b => b !== null)
// 存在すればそれを返す
if ((priorityBranches?.length ?? 0) > 0) {
  core.info('優先投入対象を利用しました')
  core.setOutput('branches', priorityBranches)
  return
}

// 投入対象のカードを確認
const branches = cards
  ?.map(n => (n?.fieldValueByName?.__typename == "ProjectV2ItemFieldSingleSelectValue" && n.fieldValueByName.name == targetStatusName && n.content?.__typename == "PullRequest") ? n.content.headRefName : null)
  .filter(b => b !== null)
core.info('優先投入対象は利用していません')
core.setOutput('branches', branches)

所以,将这些参数信息写入 action.yml 即可完成 action。

name: 'manage-projectv2-for-pr'
description: 'PullRequestに対してのProjectV2の操作をするヤツ'
inputs:
  token:
    description: 'GitHubのAPIトークン'
    required: true
  organization:
    description: '組織'
    required: true
  project-number:
    description: 'プロジェクトID'
    required: true
  target-repository-name:
    description: '取得モード時のターゲットとなるリポジトリ名(フル)'
  target-status-name:
    description: '取得モード時のターゲットとなるステータス名'
  priority-target-status-name:
    description: '取得モード時の優先ターゲットとなるステータス名'
runs:
  using: 'node16'
  main: 'dist/index.js'

当你要实际推动并执行这个时候,记得先构建再提交,不要忘记,因为我当时没有什么头绪,浪费了两个小时。

PullRequest 的同步

使用 PR Actions,我们可以为希望进行同步的每个存储库的 actions 写一个在 PR 事件中执行的 actions 的工作流程。
当然,如果要使用 Projects 的功能来进行自动化,则无需上述的操作。

name: Sync PullRequest Item

on:
  pull_request:
    types: [opened, reopened, closed]

jobs:
  sync-pullrequest:
    name: Sync PullRequest Item
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: actions/checkout@v3
      - name: generate token
        id: generate_token
        uses: actions/create-github-app-token@v1
        with:
          app-id: ${{ vars.HOGE_APP_ID }}
          private-key: ${{ secrets.HOGE_PRIVATE_KEY }}
      - name: sync item
        uses: hoge/manage-projectv2-for-pr@master
        with:
          token: ${{ steps.generate_token.outputs.token }}
          organization: hogehoge
          project-number: 1

现在,将针对这个指定的 project-number 同步 PullRequest 的卡片。请注意,Projects 是在仓库之外的资源,因此需要先获取 GitHub Apps 等的令牌。由于现在可以在整个组织中设置 secrets 等,因此强烈建议您进行设置。

合并(并构建)

- name: Checkout hoge repository
  uses: actions/checkout@v3
  with:
    fetch-depth: 0
    repository: hoge
    path: hoge
    ref: master
    token: ${{ token }}
- name: Get nanka branches
  uses: hoge/manage-projectv2-for-pr@master
  id: branches
  with:
    token: ${{ token }}
    organization: hoge
    project-number: 1
    target-repository-name: hoge/hoge
    priority-target-status-name: dev優先投入対象
    target-status-name: dev投入対象
- name: Merge branches
  if: steps.branches.outputs.branches != '[]'
  working-directory: hoge
  run: |
    git config user.name  "actions"
    git config user.email "actions@github.com"
    git fetch
    git merge origin/${{ join(fromJson(steps.branches.outputs.branches), ' origin/') }}

不处理构建过程,但可以通过为之前创建的动作附加参数并调用它,然后根据结果的存在与否适当地使用 git 命令执行合并。
之后只需对该目录执行任何操作。

需要注意的要点。

actions/checkout で fetch-depth を 0 にしないと過去のコミットが存在しない状態になるためマージ対象が同じ親を持たない状態として扱われてしまいマージすることができない
GitHub のトークンを取得するときにデフォルトでは実行中のリポジトリのみにしか許可が与えられていないトークンが取得される

この状態では各カードが REDACTED というレスポンスになりブランチ名などが取得できなくなってしまいますので、しっかりリポジトリを含む該当のプロジェクトを持っているユーザーを指定してトークンを取得するようにしておきましょう。(僕はこれに気付かず1日消し飛ばしました)

题目

目前,我已經能夠粗略地進行合併了,一看起來很方便(這只是我的個人感想),但問題還有很多。

    • dev 環境が複数できたらどうする?

 

    • いくつかのブランチを組み合わせてプリセット作りたくなったらどうする?

 

    • 投入時にコンフリクト起こしたらわざわざ見に行く必要がある

 

    ブランチをプッシュした後 PullRequest を作成しなければならない

嗯,可能有点困难!从功能上来看,应该并没有太大的变化,因此我想实际上逐渐开始使用,并观察情况和使用便利性。
(我开始觉得最好快速容器化并快速建立多个环境。)

最后

虽然做得有些粗糙,但我已经尝试在GitHub项目中实现低成本的分支管理。

这种程度的笑话谁都能想到吧!?从一开始就发布PR,然后合并到dev分支并提交提交,这样的小花招岂不是更好!?似乎会有各种各样的意见涌上心头(在写这篇文章的时候我就感觉到了),但作为圣诞倒数日的一个笑话,我暂时选择闭上眼睛…。

顺便提一下,关于优先投入的对象,预计是在投入之前,例如以 PR 单独的形式部署到开发环境并进行操作确认的情况。

对不起,刚才我没有考虑清楚。实际上,我很少写过TypeScript,所以我的代码可能非常糟糕。请只把它当作是我对于这种操作的简单认识。

最后的宣传可能有些自作主张,但是作为个人爱好,我参加了2023年的防灾应用程序开发圣诞日历,并发布了两篇文章。如果有兴趣的朋友,请来看一下,我会很高兴的。

再见。

bannerAds