Merge pull request '子赛题三 - Java-Gatekeeper 端到端自动化质量门禁工作流' (#131) from xxxx12/gitlink-cli:master into master

This commit is contained in:
wbtiger 2026-07-14 22:28:15 +08:00
commit 81889290d1
7 changed files with 555 additions and 0 deletions

View File

@ -0,0 +1,36 @@
target/
!.mvn/wrapper/maven-wrapper.jar
!**/src/main/**/target/
!**/src/test/**/target/
### STS ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
### IntelliJ IDEA ###
.idea
*.iws
*.iml
*.ipr
### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
build/
!**/src/main/**/build/
!**/src/test/**/build/
### VS Code ###
.vscode/
### OS ###
.DS_Store
Thumbs.db

View File

@ -0,0 +1,82 @@
这是子赛题三的端到端 Java 看门人工作流示例。
GitLink Gatekeeper - 基于 Java 的 AI 智能代码质量门禁系统
项目定位与核心价值)
本项目专为 2026 GitLink 自动化工作流大赛-子赛题三打造。
在开源社区中,维护者往往需要花费大量时间评审 Pull Request (PR)。`GitLink Gatekeeper` 是一个端到端的自动化解决方案,它将 `gitlink-cli` 的核心能力与 Java 强大的后端控制逻辑相结合,充当社区的\*\*“代码质量看门人”。
核心价值:
1\. 全自动审计:无需人工干预,一键自动拉取 PR 元数据并调用核心 AI 能力生成评审报告。
2\. 高鲁棒性分流:具备状态感知能力,针对已关闭/已合并的 PR 自动执行安全降级策略(转为日志留痕),防止接口报错或污染历史代码。
3\. 环境自适应:内置底层环境变量清洗与字符流重映射,彻底解决 Windows/Linux 跨平台执行时的网络代理死锁及控制台乱码问题。
\---
工作流架构设计3个及以上命令串联
本工作流通过 Java 进程控制,完美串联了以下能力链条:
1\. 步骤一:元数据侦测 —— 调起 `gitlink-cli pr +view` 抓取目标 PR 的实时状态与技术描述文本。
2\. 步骤二AI 智能评审 —— 驱动 `gitlink-cli workflow +pr-summary` 核心 Skill 对代码 Diff 进行深度分析,生成 Markdown 格式的摘要。
3\. 步骤三:动态决策回写 —— 解析步骤一状态,若 PR 开放则调用 `gitlink-cli pr +review` 自动回写 Approved 评论;若 PR 已关闭,则优雅回退至本地日志记录。
\---
快速开始与复现指南
为了保证评委与维护者能够 \*\*100% 完美复现\*\* 本工作流,请遵循以下步骤:
1\. 前置环境准备
Java 环境JDK 17 或更高版本
构建工具Apache Maven 3.6+
官方工具:已全局安装 `gitlink-cli` 并且已通过 `gitlink-cli auth login` 成功登录您的账户。
2\. 搬运与配置
确保本项目放置在官方主仓库的指定路径下:
`gitlink-cli/examples/workflows/java-gatekeeper/`
3\. 一键运行命令
打开终端,切换到当前工作流根目录,直接执行以下 Maven 命令即可拉起工作流:
```cmd
mvn spring-boot:run

View File

@ -0,0 +1,52 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.4.5</version>
<relativePath/>
</parent>
<groupId>com.gatekeeper</groupId>
<artifactId>gitlink-gatekeeper</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>GitLink Gatekeeper</name>
<description>GitLink 自动化工作流大赛 - 子赛题三</description>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@ -0,0 +1,66 @@
package com.gatekeeper;
import com.gatekeeper.service.WorkflowService;
import com.gatekeeper.utils.CommandLineExecutor.CommandExecutionException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class GatekeeperApplication implements CommandLineRunner {
private static final String SEPARATOR = "============================================================";
@Autowired
private WorkflowService workflowService;
public static void main(String[] args) {
SpringApplication.run(GatekeeperApplication.class, args);
}
@Override
public void run(String... args) {
System.out.println(SEPARATOR);
System.out.println(" GitLink Gatekeeper · PR 自动化评审工作流");
System.out.println(" 测试目标Gitlink / forgeplus · PR #42");
System.out.println(SEPARATOR);
try {
String summaryReport = workflowService.runPrReviewWorkflow(
"Gitlink", "forgeplus", "42");
System.out.println();
System.out.println("【工作流摘要预览】");
System.out.println(summaryReport);
System.out.println();
System.out.println(SEPARATOR);
System.out.println(" 工作流执行成功,已结束");
System.out.println(SEPARATOR);
} catch (CommandExecutionException e) {
System.err.println();
System.err.println("【命令执行失败】" + e.getMessage());
if (e.getCommandLine() != null) {
System.err.println("命令:" + e.getCommandLine());
}
e.printStackTrace();
printFailureFooter();
} catch (IllegalArgumentException e) {
System.err.println();
System.err.println("【参数错误】" + e.getMessage());
printFailureFooter();
} catch (Exception e) {
System.err.println();
System.err.println("【工作流执行异常】" + e.getMessage());
e.printStackTrace();
printFailureFooter();
}
}
private static void printFailureFooter() {
System.err.println();
System.err.println(SEPARATOR);
System.err.println(" 工作流执行失败,已结束");
System.err.println(SEPARATOR);
}
}

View File

@ -0,0 +1,153 @@
package com.gatekeeper.service;
import com.gatekeeper.utils.CommandLineExecutor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
/**
* GitLink 自动化 PR 评审工作流通过 {@code gitlink-cli} 串联查看 PR 摘要 回写评论
*/
@Slf4j
@Service
public class WorkflowService {
/** 本机 npm 全局安装的 gitlink-cli 可执行文件Windows .cmd 脚本) */
private static final String GITLINK_CLI =
"C:\\Users\\jmqxx\\AppData\\Roaming\\npm\\gitlink-cli.cmd";
/**
* 执行完整的 PR 自动化评审工作流
*
* @param owner 仓库所有者组织或用户
* @param repo 仓库名称
* @param prNumber Pull Request 编号
* @return 步骤二生成的 Markdown 摘要报告 {@code summaryReport}
*/
public String runPrReviewWorkflow(String owner, String repo, String prNumber) {
validateParams(owner, repo, prNumber);
log.info("开始执行 PR 自动化评审工作流owner={}, repo={}, prNumber={}", owner, repo, prNumber);
// 步骤一获取 PR 状态使用 --id避免 -i cmd /c 链路中被误解析
log.info("[步骤一] 正在获取 PR 状态…");
String prViewOutput = viewPullRequest(owner, repo, prNumber);
if (isCliFailure(prViewOutput)) {
log.warn("[步骤一] PR 查询可能失败,仍将尝试后续步骤。输出:\n{}", prViewOutput);
} else {
log.info("[步骤一] PR 状态查询完成,输出如下:\n{}", prViewOutput);
}
// 步骤二优先 workflow +pr-summary本地 CLI 不支持时回退为 pr +view 文本摘要
log.info("[步骤二] 正在生成 PR 摘要报告…");
String summaryReport = generateSummaryReport(owner, repo, prNumber, prViewOutput);
if (!StringUtils.hasText(summaryReport)) {
log.warn("[步骤二] 摘要报告为空,后续仍将尝试以评论形式回写");
summaryReport = buildFallbackSummary(prViewOutput);
} else {
log.info("[步骤二] 摘要报告已就绪,字符数={}", summaryReport.length());
if (log.isDebugEnabled()) {
log.debug("[步骤二] 摘要报告内容:\n{}", summaryReport);
}
}
// 步骤三本地 CLI pr +review使用 pr +comment 将摘要回写为 PR 评论
log.info("[步骤三] 正在通过 pr +comment 回写评审摘要…");
String commentOutput = postPullRequestComment(owner, repo, prNumber, summaryReport);
log.info("[步骤三] 评论已提交CLI 输出如下:\n{}", commentOutput);
log.info("PR 自动化评审工作流执行完毕owner={}, repo={}, prNumber={}", owner, repo, prNumber);
return summaryReport;
}
/** 调用 {@code pr +view}PR 编号使用官方长参数 {@code --id}(等价于 --number。 */
private String viewPullRequest(String owner, String repo, String prNumber) {
return CommandLineExecutor.run(
GITLINK_CLI, "pr", "+view",
"--owner", owner,
"--repo", repo,
"--id", prNumber);
}
/**
* 生成摘要先尝试 {@code workflow +pr-summary}失败或不可用时用步骤一的 PR 详情构造 Markdown 回退摘要
*/
private String generateSummaryReport(
String owner, String repo, String prNumber, String prViewOutput) {
try {
log.info("[步骤二] 尝试官方命令 workflow +pr-summary …");
String aiSummary = CommandLineExecutor.run(
GITLINK_CLI, "workflow", "+pr-summary",
"--owner", owner,
"--repo", repo,
"--number", prNumber,
"--format", "markdown");
if (!isCliFailure(aiSummary) && StringUtils.hasText(aiSummary)) {
log.info("[步骤二] 已使用 workflow +pr-summary 生成摘要");
return aiSummary;
}
log.warn("[步骤二] workflow +pr-summary 无有效输出,启用 pr +view 回退。原因片段:{}",
truncateForLog(aiSummary, 200));
} catch (Exception e) {
log.warn("[步骤二] workflow +pr-summary 调用异常,启用 pr +view 回退:{}", e.getMessage());
}
return buildFallbackSummary(prViewOutput);
}
/** 使用 {@code pr +comment} 将摘要作为评论发表(当前 CLI 版本不支持 pr +review。 */
private String postPullRequestComment(
String owner, String repo, String prNumber, String summaryReport) {
String body = StringUtils.hasText(summaryReport)
? summaryReport
: buildFallbackSummary("");
return CommandLineExecutor.run(
GITLINK_CLI, "pr", "+comment",
"--owner", owner,
"--repo", repo,
"--id", prNumber,
"--body", body);
}
private static String buildFallbackSummary(String prViewOutput) {
StringBuilder builder = new StringBuilder();
builder.append("## PR 自动化摘要(本地回退)").append(System.lineSeparator()).append(System.lineSeparator());
if (StringUtils.hasText(prViewOutput)) {
builder.append(prViewOutput.trim());
} else {
builder.append("_未能获取 PR 详情,请检查 owner/repo/PR 编号及 gitlink-cli 登录状态。_");
}
return builder.toString();
}
/** 根据 CLI 合并输出判断命令是否执行失败(不抛异常,仅用于回退逻辑)。 */
private static boolean isCliFailure(String output) {
if (!StringUtils.hasText(output)) {
return true;
}
String normalized = output.toLowerCase();
return normalized.contains("unknown command")
|| normalized.contains("unknown shorthand flag")
|| normalized.contains("unknown flag")
|| normalized.contains("error:")
|| normalized.contains("failed");
}
private static String truncateForLog(String text, int maxLen) {
if (text == null) {
return "";
}
String trimmed = text.replace('\n', ' ').trim();
return trimmed.length() <= maxLen ? trimmed : trimmed.substring(0, maxLen) + "";
}
private static void validateParams(String owner, String repo, String prNumber) {
if (!StringUtils.hasText(owner)) {
throw new IllegalArgumentException("owner 不能为空");
}
if (!StringUtils.hasText(repo)) {
throw new IllegalArgumentException("repo 不能为空");
}
if (!StringUtils.hasText(prNumber)) {
throw new IllegalArgumentException("prNumber 不能为空");
}
}
}

View File

@ -0,0 +1,165 @@
package com.gatekeeper.utils;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.Charset;
import java.nio.charset.UnsupportedCharsetException;
import java.util.Arrays;
import java.util.stream.Collectors;
/**
* 命令行执行工具通过 {@link ProcessBuilder} 在子进程中静默运行外部命令
* 并合并捕获标准输出与错误输出
*/
public final class CommandLineExecutor {
/**
* 子进程输出编码Windows 控制台多为 GBK避免中文乱码其他系统使用 JVM 默认编码
*/
private static final Charset OUTPUT_CHARSET = resolveOutputCharset();
private CommandLineExecutor() {
}
/**
* 在系统后台静默执行一条外部命令将标准输出与错误输出合并为一段文本返回
*
* <p>示例{@code run("C:\\path\\to\\gitlink-cli.cmd", "user", "+me")}</p>
* <p>Windows 下若首参数为 {@code .cmd}/{@code .bat}将自动通过 {@code cmd.exe /c} 启动</p>
*
* @param command 可执行文件路径或名称以及后续参数不可为空
* @return 子进程的标准输出与错误输出的完整文本无输出时返回空字符串
* @throws IllegalArgumentException 未传入任何命令片段时
* @throws CommandExecutionException 进程无法启动被中断或等待结束时发生 I/O 错误时
*/
public static String run(String... command) {
if (command == null || command.length == 0) {
throw new IllegalArgumentException("命令参数不能为空");
}
for (int i = 0; i < command.length; i++) {
if (command[i] == null || command[i].isBlank()) {
throw new IllegalArgumentException("命令参数第 " + (i + 1) + " 段不能为空");
}
}
String[] processCommand = normalizeCommandForWindows(command);
String commandLine = Arrays.stream(processCommand).collect(Collectors.joining(" "));
ProcessBuilder processBuilder = new ProcessBuilder(processCommand);
// 清空代理环境变量避免子进程继承终端代理导致连接超时
processBuilder.environment().put("http_proxy", "");
processBuilder.environment().put("https_proxy", "");
processBuilder.environment().put("all_proxy", "");
// 不向控制台继承标准流在后台以管道方式静默执行
processBuilder.redirectErrorStream(true);
Process process;
try {
process = processBuilder.start();
} catch (IOException e) {
throw new CommandExecutionException(
"无法启动命令进程:" + commandLine, commandLine, e);
}
try {
String output = readAllText(process.getInputStream());
process.waitFor();
// 无论退出码是否为 0均返回已捕获的标准输出与错误输出合并文本
return output;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
process.destroyForcibly();
throw new CommandExecutionException(
"等待命令执行结果被中断:" + commandLine, commandLine, e);
} catch (IOException e) {
process.destroyForcibly();
throw new CommandExecutionException(
"读取命令输出时发生 I/O 错误:" + commandLine, commandLine, e);
} finally {
if (process.isAlive()) {
process.destroyForcibly();
}
}
}
private static Charset resolveOutputCharset() {
if (isWindows()) {
try {
return Charset.forName("GBK");
} catch (UnsupportedCharsetException ignored) {
return Charset.defaultCharset();
}
}
return Charset.defaultCharset();
}
/**
* Windows 下通过 {@code cmd.exe /c} 执行批处理脚本避免 ProcessBuilder 无法直接启动 {@code .cmd}/{@code .bat}
*/
private static String[] normalizeCommandForWindows(String... command) {
if (!isWindows()) {
return command;
}
String executable = command[0].toLowerCase();
if (!executable.endsWith(".cmd") && !executable.endsWith(".bat")) {
return command;
}
String[] wrapped = new String[command.length + 2];
wrapped[0] = resolveWindowsCmdExe();
wrapped[1] = "/c";
System.arraycopy(command, 0, wrapped, 2, command.length);
return wrapped;
}
private static boolean isWindows() {
String osName = System.getProperty("os.name", "");
return osName.toLowerCase().contains("win");
}
/** 优先使用系统 ComSpec回退到默认 cmd.exe 绝对路径 */
private static String resolveWindowsCmdExe() {
String comSpec = System.getenv("ComSpec");
if (comSpec != null && !comSpec.isBlank()) {
return comSpec;
}
return "C:\\Windows\\System32\\cmd.exe";
}
/**
* 从子进程输入流中读取全部文本已合并 stderr 时即为标准输出 + 错误输出
*/
private static String readAllText(InputStream inputStream) throws IOException {
StringBuilder builder = new StringBuilder();
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(inputStream, OUTPUT_CHARSET))) {
String line;
while ((line = reader.readLine()) != null) {
if (!builder.isEmpty()) {
builder.append(System.lineSeparator());
}
builder.append(line);
}
}
return builder.toString();
}
/**
* 命令执行失败时抛出的运行时异常携带便于排查的完整命令行信息
*/
public static class CommandExecutionException extends RuntimeException {
private final String commandLine;
public CommandExecutionException(
String message, String commandLine, Throwable cause) {
super(message, cause);
this.commandLine = commandLine;
}
/** 实际执行的完整命令行(参数以空格拼接,便于日志排查) */
public String getCommandLine() {
return commandLine;
}
}
}

View File

@ -0,0 +1 @@
spring.application.name=gitlink-gatekeeper