持续交付之解决Jenkins集成编译获取代码提交记录及钉钉通知

721 阅读5分钟

「这是我参与2022首次更文挑战的第28天,活动详情查看:2022首次更文挑战

一、背景

我们在使用 Jenkins 集成编译完成后,会主动向项目组钉钉群推送编译和打包通知,方便测试同学下载测试。但同时带来一个新的需求,项目组同学想从通知中快速了解代码变更内容。我们知道开发同学在 Git 提交代码的时候都有修改注释,所以思考是否能直接获取代码的修改注释显示在最终的编译完成的通知中,直观显示给项目组每个人。

二、解决方案

搜索了一遍发现 Jenkins 没有官方插件来解决这个问题,倒是有一个老外开源的插件可用。

落地方案:

  • Jenkins Changelog Environment Plugin 实现获取代码提交记录
  • 使用 Python 获取代码提交记录并推送钉钉通知

1、下载插件源码

Git Clone github.com/daniel-beck… 插件源码

2、源码简要分析

代码骨架结构如下:

└─src
    ├─main
    │  ├─java
    │  │  └─org
    │  │      └─jenkinsci
    │  │          └─plugins
    │  │              └─changelogenvironment
    │  │                      ChangelogEnvironmentContributor.java  # 插件具体实现类
    │  │
    │  └─resources
    │      │  index.jelly  # 插件的概要描述,可以在插件列表中看到
    │      │
    │      └─org
    │          └─jenkinsci
    │              └─plugins
    │                  └─changelogenvironment
    │                      │  Messages.properties  #GUI配置文件
    │                      │  Messages_de.properties #GUI配置文件
    │                      │
    │                      └─ChangelogEnvironmentContributor
    │                              config.jelly             # 插件在单个job中的配置
    │                              config_de.properties     # 配置文件
    │                              help-dateFormat.html 	# jelly文件中属性的帮助文件
    │                              help-dateFormat_de.html 	# 同上
    │                              help-entryFormat.html	# 同上
    │                              help-entryFormat_de.html # 同上
    │                              help-lineFormat.html		# 同上
    │                              help-lineFormat_de.html	# 同上
    │
    └─test
        └─java
            └─org
                └─jenkinsci
                    └─plugins
                        └─changelogenvironment
                                ChangelogEnvironmentContributorTest.java  # 单元测试类
│  pom.xml

index.jelly: 插件的概要描述,可以在插件列表中看到

<?jelly escape-by-default='true'?>
<div>
  This plugin allows you to add changelog information to the build environment for use in build scripts.
</div>

Jenkins 展示效果: 在这里插入图片描述 pom.xml:name 属性值就是插件管理页面中的插件名称,如下:

<name>Changelog Environment Plugin</name>

*html:jelly文件中属性的帮助文件,点击插件的“?”即可展示:

<p>This field specifies the Java <code>String</code> format of the changelog entry header. It accepts four <code>String</code> placeholders:
   <ol>
       <li>The author of the commit</li>
       <li>The ID or revision of the commit</li>
       <li>The commit message</li>
       <li>The date of the commit in the format specified in the <em>Date Format</em> field</li>
   </ol>
   After this commit information, a list of affected paths will be printed in the <em>File Item Format</em>.
</p>

帮助文件在 Jenkins GUI 效果如下:在这里插入图片描述

接下来我们来看下 ChangelogEnvironmentContributor.java

public class ChangelogEnvironmentContributor extends SimpleBuildWrapper {

   private String entryFormat;

   private String lineFormat;

   private String dateFormat;

   @DataBoundConstructor
   public ChangelogEnvironmentContributor() {
       // need empty constructor so Stapler creates instances
   }

   @DataBoundSetter
   public void setDateFormat(String dateFormat) {
       this.dateFormat = dateFormat;
   }

   @DataBoundSetter
   public void setEntryFormat(String entryFormat) {
       this.entryFormat = entryFormat;
   }

   @DataBoundSetter
   public void setLineFormat(String lineFormat) {
       this.lineFormat = lineFormat;
   }

   public String getEntryFormat() {
       return this.entryFormat;
   }

   public String getLineFormat() {
       return this.lineFormat;
   }

   public String getDateFormat() {
       return this.dateFormat;
   }

   @Override
   public void setUp(Context context, Run<?, ?> build, FilePath workspace, Launcher launcher, TaskListener listener, EnvVars initialEnvironment) throws IOException, InterruptedException {
       StringBuilder sb = new StringBuilder();

       DateFormat df;
       try {
           df = new SimpleDateFormat(Util.fixNull(dateFormat));
       } catch (IllegalArgumentException ex) {
           listener.error("Failed to insert changelog into the environment: Illegal date format");
           return;
       }

       try {
           if (build instanceof AbstractBuild<?, ?>) {
               AbstractBuild<?, ?> abstractBuild = (AbstractBuild<?, ?>) build;
               ChangeLogSet cs = abstractBuild.getChangeSet();
               processChangeLogSet(sb, cs, df);
           }

           try {
               // FIXME TODO I have no idea whether this works, untested
               if (build instanceof WorkflowRun) {
                   WorkflowRun wfr = (WorkflowRun) build;
                   List<ChangeLogSet<? extends ChangeLogSet.Entry>> changeLogSets = wfr.getChangeSets();
                   for (ChangeLogSet<? extends ChangeLogSet.Entry> changeLogSet : changeLogSets) {
                       processChangeLogSet(sb, changeLogSet, df);
                   }
               }
           } catch (NoClassDefFoundError ncder) {
               // ignore
           }

       } catch (IllegalFormatException ex) {
           listener.error("Failed to insert changelog into the environment: " + ex.getMessage());
           return;
       }

       String value = sb.toString();
       if (!"".equals(value)) {
           context.env("SCM_CHANGELOG", value);
       }
   }

   private void processChangeLogSet(StringBuilder sb, ChangeLogSet cs, DateFormat df) {
       for (Object o : cs) {
           ChangeLogSet.Entry e = (ChangeLogSet.Entry) o;
           sb.append(String.format(Util.fixNull(this.entryFormat), e.getAuthor(), e.getCommitId(), e.getMsg(), df.format(new Date(e.getTimestamp()))));

           try {
               for (ChangeLogSet.AffectedFile file : e.getAffectedFiles()) {
                   sb.append(String.format(Util.fixNull(this.lineFormat), file.getEditType().getName(), file.getPath()));
               }
           } catch (UnsupportedOperationException ex) {
               // early versions of SCM did not support getAffectedFiles, only had getAffectedPaths
               for (String file : e.getAffectedPaths()) {
                   sb.append(String.format(Util.fixNull(this.lineFormat), "", file));
               }
           }
       }
   }

   @Extension
   public static class ChangelogEnvironmentContributorDescriptor extends BuildWrapperDescriptor {

       @Override
       public boolean isApplicable(AbstractProject<?, ?> item) {
           // only really makes sense for jobs with SCM, but cannot really not show this option otherwise
           // users would have to leave the config form between setting up SCM and this.
           return true;
       }

       @Override
       public String getDisplayName() {
           return Messages.DisplayName();
       }

       public FormValidation doCheckLineFormat(@QueryParameter String lineFormat) {
           try {
               String result = String.format(lineFormat, "add", "README.md");
               return FormValidation.ok(Messages.LineFormat_Sample(result));
           } catch (IllegalFormatException ex) {
               return FormValidation.error(Messages.LineFormat_Error());
           }
       }

       public FormValidation doCheckEntryFormat(@QueryParameter String entryFormat) {
           try {
               String result = String.format(entryFormat, "danielbeck", "879e6fa97d79fd", "Initial commit", 1448305200000L);
               return FormValidation.ok(Messages.EntryFormat_Sample(result));
           } catch (IllegalFormatException ex) {
               return FormValidation.error(Messages.EntryFormat_Error());
           }
       }

       public FormValidation doCheckDateFormat(@QueryParameter String dateFormat) {
           try {
               String result = new SimpleDateFormat(dateFormat).format(new Date(1448305200000L));
               return FormValidation.ok(Messages.DateFormat_Sample(result));
           } catch (IllegalArgumentException ex) {
               return FormValidation.error(Messages.DateFormat_Error());
           }
       }
   }
}
  • ChangelogEnvironmentContributor:继承 SimpleBuildWrapper,是 BuildWrapper 的一个公共抽象类,其是可扩展点,用于执行构建过程的前 / 后操作,比如准备构建的环境,设置环境变量等

  • 实现 setUp 方法,主要为获取代码提交记录,几个参数:

    • Context context:当前上下文
    • Run build:当前构建任务
    • FilePath workspace:文件路径
    • Launcher launcher:启动构建
    • TaskListener listener:检查构建状态
    • EnvVars initialEnvironment:环境变量
  • @DataBoundConstructor:插件的构造函数,此处留空以便 Stapler 创建实例

  • @DataBoundSetter:标识插件属性的 setter 方法

  • @Extension:扩展点注释,Jenkins 通过此注解自动发现扩展点,并加入扩展列表

判断构建是否为基本实例,AbstractBuild 是基本构建实例

  if (build instanceof AbstractBuild<?, ?>) {
               AbstractBuild<?, ?> abstractBuild = (AbstractBuild<?, ?>) build;
               ChangeLogSet cs = abstractBuild.getChangeSet();
               processChangeLogSet(sb, cs, df);
           }

获取合并到当前构建中的所有更改信息

ChangeLogSet cs = abstractBuild.getChangeSet();

判断构建是否为工作流实例,WorkflowRun 是工作流实例,类似 pipeline?(此处没有查到 API),这里作者说可能失效,未经过验证。

// FIXME TODO I have no idea whether this works, untested
                if (build instanceof WorkflowRun) {
                    WorkflowRun wfr = (WorkflowRun) build;
                    List<ChangeLogSet<? extends ChangeLogSet.Entry>> changeLogSets = wfr.getChangeSets();
                    for (ChangeLogSet<? extends ChangeLogSet.Entry> changeLogSet : changeLogSets) {
                        processChangeLogSet(sb, changeLogSet, df);
                    }
                }

getChangeSets 为获取当前与此项关联的所有更改日志

  List<ChangeLogSet<? extends ChangeLogSet.Entry>> changeLogSets = wfr.getChangeSets();

processChangeLogSet:自定义处理当前更改日志,主要为格式化和拼接日志

    private void processChangeLogSet(StringBuilder sb, ChangeLogSet cs, DateFormat df) {
        for (Object o : cs) {
            ChangeLogSet.Entry e = (ChangeLogSet.Entry) o;
            sb.append(String.format(Util.fixNull(this.entryFormat), e.getAuthor(), e.getCommitId(), e.getMsg(), df.format(new Date(e.getTimestamp()))));

            try {
                for (ChangeLogSet.AffectedFile file : e.getAffectedFiles()) {
                    sb.append(String.format(Util.fixNull(this.lineFormat), file.getEditType().getName(), file.getPath()));
                }
            } catch (UnsupportedOperationException ex) {
                // early versions of SCM did not support getAffectedFiles, only had getAffectedPaths
                for (String file : e.getAffectedPaths()) {
                    sb.append(String.format(Util.fixNull(this.lineFormat), "", file));
                }
            }
        }
    }

最后变更日志赋值给 SCM_CHANGELOG 变量

  String value = sb.toString();
       if (!"".equals(value)) {
           context.env("SCM_CHANGELOG", value);
       }

ChangelogEnvironmentContributorDescriptor:配置类,这里主要对GUI输入进行格式验证,通过 config.jelly 配置文件进行参数设置:

<?jelly escape-by-default='true'?>
<j:jelly xmlns:j="jelly:core" xmlns:st="jelly:stapler" xmlns:d="jelly:define" xmlns:l="/lib/layout" xmlns:t="/lib/hudson" xmlns:f="/lib/form">
   <f:entry title="${%Entry Format}" field="entryFormat">
       <f:textarea/>
   </f:entry>
   <f:entry title="${%File Item Format}" field="lineFormat">
       <f:textarea/>
   </f:entry>
   <f:entry title="${%Date Format}" field="dateFormat">
       <f:textbox/>
   </f:entry>
</j:jelly>
  • doCheckLineFormat:文件验证
  • doCheckEntryFormat:变更日志验证
  • doCheckDateFormat:时间格式验证

3、编译源码生成 hpi

切换到程序根目录 changelog-environment-plugin-master 下,执行:

mvn verify

等待一段比较长的时间,会在 ./target/ 下生成一个 changelog-environment.hpi 文件,这个就是我们需要的插件 在这里插入图片描述 把生成的插件文件上传到 Jenkins 在这里插入图片描述

4、Jenkins Job设置

项目的配置中,构建环境下面多了一项 Add Changelog Information to Environment,如下图: 在这里插入图片描述

  • Entry Format :添加 - %3$s (%4$s %1$s) ,参数分别为 ChangeLog、时间、提交人
  • Date Format :添加 yyyy-MM-dd HH:mm:ss 就是时间格式

顺序依据如下:

 sb.append(String.format(Util.fixNull(this.entryFormat), e.getAuthor(), e.getCommitId(), e.getMsg(), df.format(new Date(e.getTimestamp()))));

5、钉钉通知

通过 os.getenv("SCM_CHANGELOG") 获取变更日志内容

完整 Python 脚本如下:

# coding=utf-8

'''
@author: zuozewei
@file: notification.py
@time: 2019/4/25 18:00
@description:dingTalk通知类
'''
import os, jenkins, configparser, requests, json, time
from dingtalkchatbot.chatbot import DingtalkChatbot
from jsonpath import jsonpath

# 获取Jenkins变量
JOB_NAME = str(os.getenv("JOB_NAME"))
BUILD_URL = str(os.getenv("BUILD_URL")) + "console"
BUILD_VERSION = str(os.getenv("BUILD_VERSION"))
JENKINS_HOME = os.getenv("JENKINS_HOME")
BUILD_NUMBER = str(os.getenv("BUILD_NUMBER"))
SCM_CHANGELOG = str(os.getenv("SCM_CHANGELOG"))
WORKSPACE = os.getenv("WORKSPACE")

versionPath = JENKINS_HOME + "\workspace\Version.ini"

# 读取版本号
config = configparser.ConfigParser()
config.read(versionPath)
xxx_Major = config.get("xxx", "xxx_Major")
xxx_Minor = config.get("xxx", "xxx_Minor")
xxx_Build = config.get("xxx", "xxx_Build")
xxx_Revision = config.get("xxx", "xxx_Revision")
VERSION = xxx_Major + "." + xxx_Minor + "." + xxx_Build+ "." + xxx_Revision 

# 判断日志内容
if SCM_CHANGELOG == 'None':
    SCM_CHANGELOG = '- No changes'
    print("empty")
else:
    print("not empty")
    pass

def buildNotification():
    title = 'xxx编译通知'

    # 连接jenkins
    server1 = jenkins.Jenkins(url="http://xxxx.xxx.xxx.xxx:8080", username='xxx', password="xxx")
    build_info = server1.get_build_info(JOB_NAME, int(BUILD_NUMBER))
    # dict字典转json数据
    build_info_json = json.dumps(build_info)
    # 把json字符串转json对象
    build_info_jsonobj = json.loads(build_info_json)
    # 获取任务触发原因
    causes = jsonpath(build_info_jsonobj, '$.actions..shortDescription')
    print(causes[0])

    textFail = '#### ' + JOB_NAME + ' - Build # ' + BUILD_NUMBER + ' \n' + \
               '##### <font color=#FF0000 size=6 face="黑体">编译状态: ' + BUILD_STATUS + '</font> \n' + \
               '##### **版本类型**: ' + '开发版' + '\n' + \
               '##### **当前版本**: ' + VERSION + '\n' + \
               '##### **触发类型**: ' + str(causes[0]) + '\n' + \
               '##### **编译日志**:  [查看详情](' + BUILD_URL + ') \n' + \
               '##### **关注人**: @186xxxx2487 \n' + \
               '##### **更新记录**: \n' + \
               SCM_CHANGELOG + '\n' + \
               '> ###### xxx技术团队 \n '

    textSuccess = '#### ' + JOB_NAME + ' - Build # ' + BUILD_NUMBER + ' \n' + \
                  '##### **编译状态**: ' + BUILD_STATUS + '\n' + \
                  '##### **版本类型**: ' + '开发版' + '\n' + \
                  '##### **当前版本**: ' + VERSION + '\n' + \
                  '##### **触发类型**: ' + str(causes[0]) + '\n' + \
                  '##### **编译日志**: [查看详情](' + BUILD_URL + ') \n' + \
                  '##### **更新记录**: \n' + \
                  SCM_CHANGELOG + '\n' + \
                  '> ###### xxx技术团队 \n '

    if BUILD_STATUS == 'SUCCESS':
        dingText = textSuccess
    else:
        dingText = textFail
        
    sendding(title, dingText)

def sendding(title, content):
    at_mobiles = ['186xxxx2487']
    Dingtalk_access_token = 'https://oapi.dingtalk.com/robot/send?access_token=xxxxx'
    # 初始化机器人小丁
    xiaoding = DingtalkChatbot(Dingtalk_access_token)
    # Markdown消息@指定用户
    xiaoding.send_markdown(title=title, text=content, at_mobiles=at_mobiles)
    
if __name__ == "__main__":
    buildNotification()

最终几种通知的效果如下: 在这里插入图片描述 在这里插入图片描述 在这里插入图片描述

三、小结

本文带着大家从 Jenkins 插件源码到应用走了一遍,可能有些地方描述还不是很具体,不过针对大部分的 Jenkins 插件,这个套路是类似的,希望大家能有所启发。

本文源码: