forked from Gitlink/gitlink-cli
add: sub-challenge 3 java-gatekeeper workflow example
This commit is contained in:
parent
b45241dcda
commit
f3f89304ec
|
|
@ -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
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
这是子赛题三的端到端 Java 看门人工作流示例。
|
||||
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 不能为空");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
spring.application.name=gitlink-gatekeeper
|
||||
Loading…
Reference in New Issue