最近又用 Github Actions 玩出了花样。这里记录一下通过 Github Api 和 Github Actions 怎么去做组织内的 commits 统计。
之前一直有一个想法,能不能通过 github 的 api 去将组织内的工作量做成报表以方便查看每个人工作的进度以及各个 issue 的进展。做了简单的调研,发现现有的东西都不太能满足具体的需求:
然后就去评估了下自己做统计的工作量。github 早已经发布了 GraphQL 的 API 并且通过 explorer 去测试和使用其 API 的体验是相当好于是就心动了,决定自己做这个统计。
报表数据主要包括每个人的活跃数据以及每个人所涉及的 issue。
首先,我集成的是 Github 的 GraphQL API 主要用到了以下几个接口:
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
}
}
}
}
}
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
}
}
}
}
}
}
}
}
query Issue($owner: String!, $name: String!, $number: Int!) {
repository(owner: $owner, name: $name) {
issue(number: $number) {
title
url
}
}
}
使用的 Client 就是 Apollo GraphQL Client。
原始的 Commit 包含行的增加和删减,但这里有个问题:
这里我处理了前两个,第三个感觉有点复杂,并且我也觉得没必要如此仔细的量化。
node-ignore
去过滤掉那些生成代码node-ignore 是一个很不错的库,实现了 gitignore 的标准,用这个我们就可以将每个 commit 的每个文件扫一遍,过滤掉那些模板的文件后重新统计删减就好了。
这里吐槽 Github GraphQL API 发现其并没有实现具体一个 commit 下文件修改的接口,还需要我去调用 REST 的接口。
这里似乎有点不太严谨,但是似乎问题不大吧...
自从用了 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 %>>
<%_ }); -%>
<% }); -%>
<% }); -%>
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:
然后,这里在完成统计后(就是 generate report 这个步骤)将所有的数据做成了 artifact 保存了下来,方便后续的数据的追溯。
最后,通过一个 create-issue-from-file 的 github action 将 report 的内容创建成一个 issue 展示出来,展示出来就是上文的截图的效果了。
这里我为了避免每次执行都创建重复性的内容还特意把原来的 action 做了修改,提交了 PR https://github.com/aisensiy/create-issue-from-file 。
在很久之前的一篇 文章 介绍了我做的一个 RealWorld 的 SpringBoot + MyBatis 的实现。这个项目我也一直在维护,一方面是因为这是一个很好的 demo 项目,可以很好的体现一些设计思路 文章 也都说了不再重复。另一方面,我觉得也是一个新人练手不错的选择,可以让大家可以通过这个项目来入门。
最近在做 GraphQL 的调研和测试,我第一个想到的就是把这个项目添加上 GraphQL 的接口,一方面可以熟悉 GraphQL 的体系,另一方面也是个很好的机会去验证下是不是 REST 层是按照 六边形架构 或者说是 洋葱架构 那样子做成的是薄薄的一层,可以轻易的被替换掉。
当然,api 层和 application 层一点都不修改就能添加 GraphQL 是不可能的。主要是因为之前有不少的逻辑写在了 SpringBoot 的 Controller 里面了。那么这一部分的工作基本就是把大量放在 Controller 的代码挪动到 application 层。
然后就需要按照 REST api 提供一个对等的 GraphQL 的 schema 了。这里参考的是 https://github.com/thebergamo/realworld-graphql/blob/master/data/schema.graphql 。
Real World 似乎对 GraphQL 这部分的工作不太上心,这个东西已经挺久的了,但是官方并没有很好的支持。
不过它这个 schema 明显有两个问题:
unfavoriteArticle
的 Mutation
deleteArticle
返回了错误的结果,明明应该是 DeletionStatus
用 https://apis.guru/graphql-voyager/ 做展示基本就是这个样子:
schema 在 https://github.com/gothinkster/spring-boot-realworld-example-app/blob/master/src/main/resources/schema/schema.graphqls 可以看到。
可以看到 GraphQL 的 schema 是一张图,有点像是数据库的 ER Diagram。不过很显然,GraphQL 的关系肯定不是数据库级别的 CRUD 而是一个业务层级的关联图:
User
的 Profile
下可以有很多 Article
的视图 favorites
feed
;Article
则有其自己的全量属性:author
comments
;Comment
也有自己的 article
author
;这部分让我想起来 DDD 书中提到的 Domain Model 对象之间的关联关系(第五章 A Model Expressed in Software)。和 REST 相比,通过 GraphQL 所组织的 schema 有更好的整体性和一致性。当然,这也有可能是它的缺点:它缺少了 REST 的那种随便搞个接口的灵活性。
这里的实现采用了 Netflix DGS 这个框架,很符合 SpringBoot 的设计逻辑,并且最近也一直在密集的更新中。
从文件组织上看,每个 Entity 或者是 EntityList 可以组织一个 Datafetcher 提供给具体某一个 Query
下的特定 type 的查询。而针对某个 Entity 的 Mutation 可以放到一起?这部分不太确定,可能还是刚刚开始做,感受不是很明显。
既然是一张图,那么查询就可以是一个树,比如可以有这么一个查询:
query {
me {
profile {
articles {
edges {
node {
comments {
edges {
node {
body
}
}
}
}
}
}
}
}
}
获取当前用户的 articles
并且包含它的 comment
的 body
。类似的问题在 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 那样担心大量的手工数据组合。
随着最近又更深入的使用了 Github Actions 对其又有了一些新的认识。这里对原来的一些些观点做一些补充。
再之前的一篇 文章 对 github actions 做了一些简单的介绍,距离上次记录已经超过半年的时间了,在这段时间我们逐渐将大部分的 circleci 执行的 ci/cd 内容挪动到 github actions 了。这里在对目前的一些使用体会做一个记录。
github actions 配合 github 自身的 api 可以玩出很多花样,把很多手工的工作都变成自动化的流程,确实节省了不少时间。其实每个东西都没什么复杂的,这里主要是罗列和记录吧。
首先,我们通常都需要在打 tag 的时候搞出来一个发布。github actions 可以在 push tag 的时候触发相应的流程,实现这个功能。
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 分别是准备要发布的内容以及执行实际的发布:
app.jar
。然后使用工具 git-chglog 生成当前 tag 的 changelog 保存到 CHANGELOG.md
中。同样是在打 tag 的时候做一个发布,openbayes 的命令行工具要做的事情就会多不少:
由于这个项目是 golang 开发的,我们使用了一个名为 goreleaser 的工具。它能够帮助我们完成如下事情:
使用 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 上,最终还是采用了将二进制文件放到国内对象存储的方案。
由于 github 在国内的访问不是很稳定,但是我们有一些样例项目希望其他人可以 git clone 去尝试,那么就有这种将 github 的项目同步到 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 }}
github action 确实通过和 api 的集成帮我们生了不少事情,但是在使用的过程中也遇到了一些问题。槽点不是很多,目前也一定程度上可以接受吧。如果能够再稳定一些那就更好了。
github action 这种模式其实本身是有点危险的:相当于把大量的用户代码在自己的系统里去跑,并且还有非常复杂的沟通与集成的工作。可能因为各种自身或者用户的问题,服务确实有 down 过那么几次。
其次就是我们一直吐槽的 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 依赖是可以跨平台的前提下的。