Eisen's Blog

© 2024. All rights reserved.

Github Actions 与 Github 自身 API 集成生成周报

2021 July-01

最近又用 Github Actions 玩出了花样。这里记录一下通过 Github Api 和 Github Actions 怎么去做组织内的 commits 统计。

为啥要自己做统计

之前一直有一个想法,能不能通过 github 的 api 去将组织内的工作量做成报表以方便查看每个人工作的进度以及各个 issue 的进展。做了简单的调研,发现现有的东西都不太能满足具体的需求:

  1. 我希望统计 org 下所有仓库的数据,而不是单个仓库(目前所在组织里有 100+ 个仓库)
  2. 目前 org 内以 issue 作为工作的一个最小单位,希望提交的 commit 都与 issue 做关联,那么势必需要统计每个周期内 issue 的进展以及关联的 commit 做统计,显然现有的 insights 也是不太够的

然后就去评估了下自己做统计的工作量。github 早已经发布了 GraphQL 的 API 并且通过 explorer 去测试和使用其 API 的体验是相当好于是就心动了,决定自己做这个统计。

先看看最终效果

报表数据主要包括每个人的活跃数据以及每个人所涉及的 issue。

2021 07 02 01 00 14

具体怎么做

用到的 GraphQL API

首先,我集成的是 Github 的 GraphQL API 主要用到了以下几个接口:

1. 获取组织内的 repositories

query Repositories($username: String!, $cursor: String) {
  organization(login: $username) {
    repositories(
      first: 100
      isLocked: false
      orderBy: { field: PUSHED_AT, direction: DESC }
      after: $cursor
    ) {
      pageInfo {
        hasNextPage
        endCursor
      }
      edges {
        node {
          pushedAt
          name
        }
      }
    }
  }
}

2. 获取 repository 下的 commits

query Commits($owner: String!, $name: String!, $cursor: String) {
  repository(owner: $owner, name: $name) {
    defaultBranchRef {
      target {
        ... on Commit {
          history(first: 30, after: $cursor) {
            pageInfo {
              hasNextPage
              endCursor
            }
            edges {
              node {
                author {
                  user {
                    name
                    login
                  }
                }
                commitUrl
                abbreviatedOid
                pushedDate
                additions
                deletions
                message
              }
            }
          }
        }
      }
    }
  }
}

3. 获取 issue

query Issue($owner: String!, $name: String!, $number: Int!) {
  repository(owner: $owner, name: $name) {
    issue(number: $number) {
      title
      url
    }
  }
}

使用的 Client 就是 Apollo GraphQL Client。

对每个人的活动量做细节处理

原始的 Commit 包含行的增加和删减,但这里有个问题:

  1. 很多代码其实是自动生成的,这部分并不能算是个人的工作量,并且这种生成代码的规模对于不同编程语言也有很大差异,希望在统计时忽略这些自动生成的代码
  2. 有一些 Merge commit 其实也不能算是个人工作量,也应该剔除
  3. 不同编程语言的工作规模有差异,比如前端 js 通常生产出来的代码规模就比写 python 的要大不少,纯粹按照行数统计没有可比性

这里我处理了前两个,第三个感觉有点复杂,并且我也觉得没必要如此仔细的量化。

通过 node-ignore 去过滤掉那些生成代码

node-ignore 是一个很不错的库,实现了 gitignore 的标准,用这个我们就可以将每个 commit 的每个文件扫一遍,过滤掉那些模板的文件后重新统计删减就好了。

这里吐槽 Github GraphQL API 发现其并没有实现具体一个 commit 下文件修改的接口,还需要我去调用 REST 的接口。

通过 commit message 过滤掉 Merge 的 commit

这里似乎有点不太严谨,但是似乎问题不大吧...

用 ejs 做模板生成 markdown

自从用了 react 似乎很少和各种模板引擎打交道了。上次使用类似的东西还是用 freemarker 做简单的文本内容模板。这次我也做了简单的调研,看了 mustache handlebars 和 ejs 最后还是觉得 ejs 这种可以直接写 js 的比较舒服。尤其是我在尝试 mustache 的时候没找到去迭代 Object 的方法...

写出来就是这个样子,实在是好久没写 nodejs 以及对 ejs 也是现学现用...

## Commit Summary

<% Object.keys(userCommitSummary).forEach(function(username) { -%>
- <%= username %>: <%= userCommitSummary[username].commitCount %> commits +<%= userCommitSummary[username].additions %> -<%= userCommitSummary[username].deletions %>
<% }); -%>

## Issue Summary

<% Object.keys(userIssueSummary).forEach(function(username) { -%>
### <%= username %>

工作涉及 <%= userIssueSummary[username].issueCount %> 个 issue:
<% userIssueSummary[username].issues.forEach(function(issue) { %>
- <%= issue -%>
<% }); %>

<% }); -%>

## Commit Details

<% Object.keys(userRepositoryCommitsDetails).forEach(function(username) { -%>
### <%= username %>
  <% Object.keys(userRepositoryCommitsDetails[username]).forEach(function(repo) { %>
####  <%= repo %>

    <%_ userRepositoryCommitsDetails[username][repo].forEach(function(commit) { -%>
- <%= commit.url %> - <%= commit.message %> <<%= commit.pushedDate %>>
    <%_ }); -%>
  <% }); -%>

<% }); -%>

用 github actions 去跑任务做周期性统计

name: statistics of github org

on:
  workflow_dispatch:
    inputs:
      timezone:
        description: "TimeZone default is +8"
        required: true
        default: "Asia/Shanghai"
      date:
        description: "Any date in the week, default is now"
        required: false
  schedule:
    # https://crontab.guru
    - cron: 0 18 * * *

jobs:
  report:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - name: Setup node
        uses: actions/setup-node@v2
        with:
          node-version: "14"
          check-latest: true
      - run: yarn
      - name: generate report
        env:
          GITHUB_TOKEN: ${{ secrets.GH_PAT }}
        run: TZ=${{github.event.inputs.timezone}} DATE=${{github.event.inputs.date}} node main.js
      - name: Upload results
        uses: actions/upload-artifact@v2
        with:
          name: reports
          path: |
            rawdata.json
            report.md
      - name: Prepare Issue Title
        id: issue_title
        run: |
          export TZ=${{github.event.inputs.timezone}}
          export DATE=${{github.event.inputs.date}}
          echo ::set-output name=ISSUE_TITLE::$(node week.js)
      - name: Create Issue From File
        uses: aisensiy/create-issue-from-file@af97df85a971093700b62d1bde8339c4fabb35ff
        with:
          title: Weekly Statistics ${{steps.issue_title.outputs.ISSUE_TITLE}}
          token: ${{ secrets.GH_PAT }}
          update-existing: true
          content-filepath: ./report.md
          labels: |
            report

首先,这里使用了两个 github workflow 的 trigger:

  • schedule 也就是 cron 用于周期性跑任务
  • workflow_dispatch 可以提供个简单的表单,用来主动去创建一个 workflow

2021 07 02 01 23 57

然后,这里在完成统计后(就是 generate report 这个步骤)将所有的数据做成了 artifact 保存了下来,方便后续的数据的追溯。

Artifact 可以直接下载下来
Artifact 可以直接下载下来

最后,通过一个 create-issue-from-file 的 github action 将 report 的内容创建成一个 issue 展示出来,展示出来就是上文的截图的效果了。

这里我为了避免每次执行都创建重复性的内容还特意把原来的 action 做了修改,提交了 PR https://github.com/aisensiy/create-issue-from-file

总结

  • GraphQL 的 API 相对于 REST 更容易上手
  • 了解了 Github Actions 除了 CI/CD 的功能外还能做很多奇奇怪怪的东西,甚至让我觉得它是一个 serverless 平台,这里还看到另外一个新奇的使用方式 https://github.blog/2020-06-17-using-github-actions-for-mlops-data-science/
  • 自己撰写 Github Actions 似乎挺简单的,文档还算是比较清楚,不过那个 outputs 确实是花了些时间

Real World 的 GraphQL 版本

2021 June-16

在很久之前的一篇 文章 介绍了我做的一个 RealWorld 的 SpringBoot + MyBatis 的实现。这个项目我也一直在维护,一方面是因为这是一个很好的 demo 项目,可以很好的体现一些设计思路 文章 也都说了不再重复。另一方面,我觉得也是一个新人练手不错的选择,可以让大家可以通过这个项目来入门。

最近在做 GraphQL 的调研和测试,我第一个想到的就是把这个项目添加上 GraphQL 的接口,一方面可以熟悉 GraphQL 的体系,另一方面也是个很好的机会去验证下是不是 REST 层是按照 六边形架构 或者说是 洋葱架构 那样子做成的是薄薄的一层,可以轻易的被替换掉。

对 api 层与 application 层做重构

当然,api 层和 application 层一点都不修改就能添加 GraphQL 是不可能的。主要是因为之前有不少的逻辑写在了 SpringBoot 的 Controller 里面了。那么这一部分的工作基本就是把大量放在 Controller 的代码挪动到 application 层。

确认 GraphQL 的 schema

然后就需要按照 REST api 提供一个对等的 GraphQL 的 schema 了。这里参考的是 https://github.com/thebergamo/realworld-graphql/blob/master/data/schema.graphql

Real World 似乎对 GraphQL 这部分的工作不太上心,这个东西已经挺久的了,但是官方并没有很好的支持。

不过它这个 schema 明显有两个问题:

  1. 少了 unfavoriteArticleMutation
  2. deleteArticle 返回了错误的结果,明明应该是 DeletionStatus

https://apis.guru/graphql-voyager/ 做展示基本就是这个样子:

2021 06 16 19 22 23

schema 在 https://github.com/gothinkster/spring-boot-realworld-example-app/blob/master/src/main/resources/schema/schema.graphqls 可以看到。

可以看到 GraphQL 的 schema 是一张图,有点像是数据库的 ER Diagram。不过很显然,GraphQL 的关系肯定不是数据库级别的 CRUD 而是一个业务层级的关联图:

  1. UserProfile 下可以有很多 Article 的视图 favorites feed
  2. Article 则有其自己的全量属性:author comments
  3. Comment 也有自己的 article author

这部分让我想起来 DDD 书中提到的 Domain Model 对象之间的关联关系(第五章 A Model Expressed in Software)。和 REST 相比,通过 GraphQL 所组织的 schema 有更好的整体性和一致性。当然,这也有可能是它的缺点:它缺少了 REST 的那种随便搞个接口的灵活性。

增加 GraphQL 的代码

这里的实现采用了 Netflix DGS 这个框架,很符合 SpringBoot 的设计逻辑,并且最近也一直在密集的更新中。

2021 06 16 20 03 53

从文件组织上看,每个 Entity 或者是 EntityList 可以组织一个 Datafetcher 提供给具体某一个 Query 下的特定 type 的查询。而针对某个 Entity 的 Mutation 可以放到一起?这部分不太确定,可能还是刚刚开始做,感受不是很明显。

对于 nested query 的处理

既然是一张图,那么查询就可以是一个树,比如可以有这么一个查询:

query {
  me {
    profile {
      articles {
        edges {
          node {
            comments {
              edges {
                node {
                  body
                }
              }
            }
          }
        }
      }
    }
  }
}

获取当前用户的 articles 并且包含它的 commentbody。类似的问题在 Nested data fetchers 有做一些阐述。而我这个例子就是遇到了 https://netflix.github.io/dgs/advanced/context-passing/#no-showid-use-local-context 这部分阐述的情况,需要自己创建一个 Map 并放到 localContext 中做传递。具体的代码如下:

ArticleDatafetcher.java:

package io.spring.graphql;

...

@DgsComponent
public class ArticleDatafetcher {

  ...

  @DgsData(parentType = PROFILE.TYPE_NAME, field = PROFILE.Articles)
  public DataFetcherResult<ArticlesConnection> userArticles(
      ...
      DgsDataFetchingEnvironment dfe) {

    User current = SecurityUtil.getCurrentUser().orElse(null);
    Profile profile = dfe.getSource();

    CursorPager<ArticleData> articles;

    ...

    graphql.relay.PageInfo pageInfo = buildArticlePageInfo(articles);
    ArticlesConnection articlesConnection =
        ArticlesConnection.newBuilder()
            .pageInfo(pageInfo)
            .edges(
                articles.getData().stream()
                    .map(
                        a ->
                            ArticleEdge.newBuilder()
                                .cursor(a.getCursor().toString())
                                .node(buildArticleResult(a))
                                .build())
                    .collect(Collectors.toList()))
            .build();
    return DataFetcherResult.<ArticlesConnection>newResult()
        .data(articlesConnection)
        .localContext( // 这里将 slug : article 的 map 传递到下一个层级了
            articles.getData().stream().collect(Collectors.toMap(ArticleData::getSlug, a -> a)))
        .build();
  }

  ...
}

CommentDatafetcher.java:

package io.spring.graphql;

...

@DgsComponent
public class CommentDatafetcher {
  ...

  @DgsData(parentType = ARTICLE.TYPE_NAME, field = ARTICLE.Comments)
  public DataFetcherResult<CommentsConnection> articleComments(
      ...
      DgsDataFetchingEnvironment dfe) {

    if (first == null && last == null) {
      throw new IllegalArgumentException("first 和 last 必须只存在一个");
    }

    User current = SecurityUtil.getCurrentUser().orElse(null);
    Article article = dfe.getSource();
    Map<String, ArticleData> map = dfe.getLocalContext(); // 获取 map
    ArticleData articleData = map.get(article.getSlug()); // 使用 slug 获取具体的 ArticleData

    CursorPager<CommentData> comments;
    
    ...

    graphql.relay.PageInfo pageInfo = buildCommentPageInfo(comments);
    CommentsConnection result =
        CommentsConnection.newBuilder()
            .pageInfo(pageInfo)
            .edges(
                comments.getData().stream()
                    .map(
                        a ->
                            CommentEdge.newBuilder()
                                .cursor(a.getCursor().toString())
                                .node(buildCommentResult(a))
                                .build())
                    .collect(Collectors.toList()))
            .build();
    return DataFetcherResult.<CommentsConnection>newResult()
        .data(result)
        .localContext(
            comments.getData().stream().collect(Collectors.toMap(CommentData::getId, c -> c))) // 这里同样传递了一个 id : comment 的 map 到下一个层级
        .build();
  }

  ...
}

这种传递 map 的方式也算是官方指定的最佳实践了吧,并且基本不可或缺。因为很多时候 Source 和实际从 Application 层传递的 DTO 的类型就是不一样的,可能缺了不少数据。

效果

只要 Application 层保证重用并且捋清楚了 DataFetcher 之间的数据传递关系,后面做起来感觉就一马平川了,毕竟这也不是一个什么很大很复杂的项目。

完成之后感觉这种网状关系的调用体验还是很好的,感觉对 API 的调用方来说,一旦熟悉了这种模式就可以更容易的组合各种比较复杂的视图,而不会像 REST 那样担心大量的手工数据组合。

开发过程中的一些小问题

  1. Intellij 对 GraphQL 本身的支持还不够好
  2. Dgs 是个比较新的框架,加上国内似乎很少有做类似东西的人,资料很少
  3. RealWorld 本身对 GraphQL 的推进也很慢,后续我应该会增加英文版本的 ReadME 介绍 GraphQL 这部分的工作,并且希望可以有前端做支持

Github Actions 的一些使用体会

2021 May-11

2021 年 7 月更新

随着最近又更深入的使用了 Github Actions 对其又有了一些新的认识。这里对原来的一些些观点做一些补充。

再之前的一篇 文章 对 github actions 做了一些简单的介绍,距离上次记录已经超过半年的时间了,在这段时间我们逐渐将大部分的 circleci 执行的 ci/cd 内容挪动到 github actions 了。这里在对目前的一些使用体会做一个记录。

Good part

github actions 配合 github 自身的 api 可以玩出很多花样,把很多手工的工作都变成自动化的流程,确实节省了不少时间。其实每个东西都没什么复杂的,这里主要是罗列和记录吧。

依据标签自动发布 release

首先,我们通常都需要在打 tag 的时候搞出来一个发布。github actions 可以在 push tag 的时候触发相应的流程,实现这个功能。

2021 05 15 12 58 38

name: Java CI

on:
  push:
    branches:
      - '**'
    tags:
      - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10
  pull_request:
    branches:
      - '**'

jobs:
  build:

    runs-on: self-hosted
    if: "! contains(github.event.head_commit.message, '[ci skip]')"

    steps:
    - uses: actions/checkout@v1
    ...
    - name: Prepare release
      if: startsWith(github.ref, 'refs/tags/')
      env:
        CURRENT_TAG: ${{ steps.version_tag.outputs.VERSION }}
      run: |
        cp build/libs/openbayes-server-0.0.1-SNAPSHOT.jar app.jar
        docker run -v "$PWD":/workdir quay.io/git-chglog/git-chglog $CURRENT_TAG > CHANGELOG.md
    - name: Release
      uses: softprops/action-gh-release@v1
      if: startsWith(github.ref, 'refs/tags/')
      with:
        body_path: CHANGELOG.md
        files: app.jar
      env:
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

如上所示,最后两个 step 分别是准备要发布的内容以及执行实际的发布:

  1. 这是一个 java 的项目,我将 build 出来的 jar 作为一个 asset 重命名成 app.jar 。然后使用工具 git-chglog 生成当前 tag 的 changelog 保存到 CHANGELOG.md 中。
  2. 使用一个第三方的 github action softprops/action-gh-release 用来创建一个 release 其实就是调用了 github 的 api 创建了一个 release 罢了。具体怎么做看文档就好。

goreleaser 与 ghr 的帮助

同样是在打 tag 的时候做一个发布,openbayes 的命令行工具要做的事情就会多不少:

  1. 作为一个需要别人下载的东西,我们需要对国内提供比较好的访问速度
  2. 作为一个私有仓库,我们需要同时将 release 放到另外的公有仓库里
  3. 我们需要更新 homebrew 的源以保证用户通过 homebrew 安装的时候是最新的版本

由于这个项目是 golang 开发的,我们使用了一个名为 goreleaser 的工具。它能够帮助我们完成如下事情:

  1. 构建针对不同操作系统的二进制文件并提供 checksum 文件
  2. 更新对应的 homebrew 仓库
  3. 将代码上传到一个国内访问速度比较快的对象存储上,见Blobs

使用 goreleaser 的 action 就可以完成以上三个事情。但是除此之外,我们还需要发布到另外一个公开的仓库里。

本想继续采用上面的那个 action-gh-release 无奈这个东西有个 bug:虽然它允许指定另外一个仓库作为发布的仓库,但是在发布前它居然会检查自己以这个名字命名的 release 是否存在(而不是去检查要发布的仓库的 release 是否存在),所以我们就继续采用了 ghr 这个古老的东西。

注意 在将东西发布到其他仓库的时候,github action 所提供的默认的 GITHUB_TOKEN 是不够的:它不具备操纵其他仓库的权限。需要自己创建一个新的 token 并通过 env 传递过来。文档见 Creating a personal access token

    - name: Release to other repository
      env:
        GITHUB_TOKEN: ${{ secrets.GH_PAT }} # 这里就是一个自己创建的 TOKEN 了
        CURRENT_TAG: ${{ steps.version_tag.outputs.VERSION }}
      run: |
        ...
        go get -u github.com/tcnksm/ghr
        ghr -u signcl -r bayes-releases -body="$(cat tmpDist/CHANGELOG.md)" $CURRENT_TAG dist

这里在吐槽一下 gitee 的 api。最开始我们是将命令行工具发布到 gitee 的一个仓库的,采用 github action 后也想通过 gitee 的 api 去创建相应的发布到 gitee 对应的仓库。无奈 gitee 的仓库居然只有创建一个空 release 的接口,而没有向 release 里面提供额外 assets 的接口,相当于这 api 就只做了一半而已,询问客服也没什么结果(不过起码有人回复),并且我们也不希望通过模拟表单提交的方式去做了,毕竟也不是非要发布到 gitee 上,最终还是采用了将二进制文件放到国内对象存储的方案。

同步到 gitee

由于 github 在国内的访问不是很稳定,但是我们有一些样例项目希望其他人可以 git clone 去尝试,那么就有这种将 github 的项目同步到 gitee 以提升可访问性的需求:

  1. 去 gitee 创建一个 access token
  2. 在 github 每次 push 的时候都触发一个 github action 将 commit 主动 push 到 gitee 那边的仓库就好了

这里我直接使用了 https://github.com/wangchucheng/git-repo-sync 这个 action 看里面的代码也就是这么个流程。


name: sync-gitee

on:
  push:
    branches:
      - '**'

jobs:
  sync:
    runs-on: ubuntu-latest
    name: Git Repo Sync
    steps:
    - uses: actions/checkout@v2
      with:
        fetch-depth: 0
    - uses: wangchucheng/git-repo-[email protected]
      with:
        target-url: ${{ secrets.REMOTE_GIT }}
        target-username: ${{ secrets.username }}
        target-token: ${{ secrets.ACCESS_TOKEN }}

Bad part

github action 确实通过和 api 的集成帮我们生了不少事情,但是在使用的过程中也遇到了一些问题。槽点不是很多,目前也一定程度上可以接受吧。如果能够再稳定一些那就更好了。

不太稳定的服务

github action 这种模式其实本身是有点危险的:相当于把大量的用户代码在自己的系统里去跑,并且还有非常复杂的沟通与集成的工作。可能因为各种自身或者用户的问题,服务确实有 down 过那么几次。

2021 05 16 11 00 21

不太完善的封装

其次就是我们一直吐槽的 action 的写法问题了:很多 action 是那种不通过 docker 封装的版本,这势必会引入依赖不全的问题。尤其是对于我们这种 self-hosted 的用户,经常会出现因为依赖不齐全而导致 ci 挂掉。

更新 目前并没有很系统的去了解撰写 github action 的方法,最近接触到的是 JS Action 的流程。其文档也提到

"To ensure your JavaScript actions are compatible with all GitHub-hosted runners (Ubuntu, Windows, and macOS), the packaged JavaScript code you write should be pure JavaScript and not rely on other binaries. JavaScript actions run directly on the runner and use binaries that already exist in the virtual environment."

也就是说其平台兼容性是建立在你自己的 nodejs 依赖是可以跨平台的前提下的。