feat(workflows): add project bootstrap automation example

This commit is contained in:
Jiachen Li 2026-05-25 22:39:40 +08:00
parent b45241dcda
commit 33ad8e62a0
18 changed files with 1338 additions and 0 deletions

View File

@ -0,0 +1 @@
outputs/

View File

@ -0,0 +1,75 @@
# GitLink 项目一键初始化与协作启动工作流
面向 GitLink 竞赛子赛题三的端到端自动化工作流示例。
本项目聚焦开源项目从 0 到可协作状态的启动过程,使用 `gitlink-cli` 串联仓库检查、分支规划、初始 Issue 创建和结果回写等能力,自动生成 README、LICENSE、CI 配置、协作文档、初始化报告和结构化清单。该流程覆盖“项目配置 -> 初始化文件生成 -> GitLink 命令编排 -> 任务落地 -> 报告归档”的完整闭环。
## 交付物
- `scripts/bootstrap_project.go`:主工作流入口
- `scripts/run_demo.ps1`:一键复现脚本
- `examples/sample_project.json`:示例项目配置
- `examples/verification_comment_config.json`:真实回写验证配置
- `examples/demo_outputs/`:固定示例输出
- `docs/architecture.md`:架构与流程说明
- `docs/quickstart.md`:最短复现路径
- `docs/runbook.md`:运行手册
- `docs/verification.md`:验证记录
- `docs/submission-checklist.md`:赛题要求映射
- `scripts/bootstrap_project_test.go`Go 单元测试
## 运行方式
进入本目录后执行 dry-run
```powershell
.\scripts\run_demo.ps1
```
执行后会生成:
- `outputs/*_bootstrap_report.md`
- `outputs/*_summary.md`
- `outputs/*_manifest.json`
- `outputs/*_files.json`
- `outputs/command_log_*.json`
如需执行真实 GitLink 写操作,在完成 GitLink 认证并核对目标仓库后使用:
```powershell
.\scripts\run_demo.ps1 -Apply
```
如需连同仓库创建一起执行:
```powershell
.\scripts\run_demo.ps1 -Apply -CreateRepo
```
如需把初始化摘要发布到指定 Issue
```powershell
.\scripts\run_demo.ps1 -Apply -PublishIssueNumber 1
```
## 工作流串联的 gitlink-cli 调用
默认配置会规划 7 个 `gitlink-cli` 调用:
1. `repo +info`
2. `branch +list`
3. `branch +create`
4. `branch +create`
5. `issue +create`
6. `issue +create`
7. `issue +create`
当指定 `-PublishIssueNumber` 时,会额外追加 `issue +comment`,用于把初始化摘要回写到 GitLink Issue。
当指定 `-CreateRepo` 时,会在检查仓库前追加 `repo +create`
## 场景价值
- 降低新开源项目启动成本,避免 README、License、CI、初始任务缺失。
- 将项目初始化过程结构化,便于团队复用和审计。
- 将 `gitlink-cli` 的仓库、分支、Issue 和评论能力串联为可复现方案。
- 支持 dry-run 和 apply 两种模式,兼顾演示稳定性和真实落地。

View File

@ -0,0 +1,32 @@
# 架构说明
本工作流采用“配置输入 -> 资产生成 -> CLI 编排 -> GitLink 落地 -> 结果归档”的五段式架构。
```mermaid
flowchart LR
A["项目配置<br/>sample_project.json"] --> B["资产生成<br/>README / LICENSE / CI / 协作文档"]
B --> C["CLI 编排<br/>repo / branch / issue / comment"]
C --> D["GitLink 项目空间<br/>仓库 / 分支 / Issue"]
D --> E["结果归档<br/>报告 / 摘要 / manifest / 命令日志"]
C --> E
```
## 模块职责
| 模块 | 职责 |
| --- | --- |
| 配置输入 | 描述项目名称、目标仓库、初始化分支和初始 Issue |
| 资产生成 | 生成 README、LICENSE、CI 配置、贡献指南和路线图 |
| CLI 编排 | 规划或执行 `gitlink-cli` 命令串联仓库、分支、Issue 和评论能力 |
| GitLink 落地 | 在真实 GitLink 仓库中创建分支、Issue并可回写摘要 |
| 结果归档 | 输出 Markdown 报告、摘要、JSON manifest 和命令日志 |
## 端到端链路
1. 读取 `examples/sample_project.json`
2. 生成初始化文件包。
3. 规划 `repo +info``branch +list` 检查目标状态。
4. 规划或执行 `branch +create` 创建协作分支。
5. 规划或执行 `issue +create` 创建初始任务。
6. 可选执行 `issue +comment` 发布初始化摘要。
7. 生成报告与命令日志,支撑复现和审计。

View File

@ -0,0 +1,56 @@
# 快速开始
## 1. 进入目录
```powershell
cd examples\workflows\project-bootstrap-automation
```
## 2. 运行 dry-run
```powershell
.\scripts\run_demo.ps1
```
该命令不会写入 GitLink只生成初始化材料和命令计划。
## 3. 查看输出
```powershell
Get-ChildItem outputs
```
重点查看:
- `*_bootstrap_report.md`
- `*_summary.md`
- `*_manifest.json`
- `command_log_*.json`
## 4. 执行单元测试
```powershell
go test ./scripts
```
## 5. 执行真实写入
确认目标仓库和认证状态后执行:
```powershell
.\scripts\run_demo.ps1 -Apply
```
执行真实写入前,应先通过 `gitlink-cli auth login` 或当前环境已配置的认证方式完成 GitLink 登录。
如需创建目标仓库:
```powershell
.\scripts\run_demo.ps1 -Apply -CreateRepo
```
如需把摘要发布到指定 Issue
```powershell
.\scripts\run_demo.ps1 -Apply -PublishIssueNumber 1
```

View File

@ -0,0 +1,43 @@
# 运行手册
## 模式说明
| 模式 | 命令 | 说明 |
| --- | --- | --- |
| dry-run | `.\scripts\run_demo.ps1` | 只生成材料和命令计划,不写入 GitLink |
| apply | `.\scripts\run_demo.ps1 -Apply` | 执行真实 `gitlink-cli` 命令 |
| apply + create repo | `.\scripts\run_demo.ps1 -Apply -CreateRepo` | 先创建仓库,再执行初始化命令 |
| apply + comment | `.\scripts\run_demo.ps1 -Apply -PublishIssueNumber 1` | 执行真实命令,并将摘要评论到指定 Issue |
## 配置文件
默认配置位于:
```text
examples/sample_project.json
```
主要字段:
- `project`:项目名称、描述、语言、许可证
- `repository`:目标 GitLink 仓库 owner/name
- `branches`:需要创建的协作分支
- `issues`:初始化 Issue 列表
- `publish.issue_number`:可选的摘要发布 Issue 编号
## 输出文件
| 文件 | 说明 |
| --- | --- |
| `*_bootstrap_report.md` | 初始化报告 |
| `*_summary.md` | 可发布到 Issue 的摘要 |
| `*_manifest.json` | 结构化初始化清单 |
| `*_files.json` | 生成文件内容包 |
| `command_log_*.json` | gitlink-cli 命令计划或执行结果 |
## 安全边界
- 默认 dry-run不进行远端写操作。
- 只有显式传入 `-Apply` 才执行真实 GitLink 命令。
- `-PublishIssueNumber` 只在明确指定 Issue 编号时追加评论命令。
- 所有命令会写入 `command_log_*.json`,便于复盘和审计。

View File

@ -0,0 +1,26 @@
# 提交核对清单
## 官方交付要求映射
| 要求 | 本项目对应内容 |
| --- | --- |
| 工作流串联不少于 3 个 CLI 命令或 Skill 调用 | `scripts/bootstrap_project.go` 规划或执行 `repo +info`、`branch +list`、`branch +create`、`issue +create`、`issue +comment` |
| 提供可复现执行脚本或 Agent 对话记录 | `scripts/run_demo.ps1` |
| 在至少一个真实 GitLink 项目上运行并展示效果 | 已在 `puygob236/gitlink-bootstrap-demo` 完成仓库读取、分支读取、Issue 创建和 Issue 摘要回写验证 |
| 提供工作流说明文档 | `README.md`、`docs/quickstart.md`、`docs/runbook.md` |
| 提供架构图 | `docs/architecture.md` |
| 代码开源并托管到 GitLink | 放置于 `examples/workflows/project-bootstrap-automation/` |
| 提供完整中文 README | `README.md` |
## 验证状态
- `go test ./examples/workflows/project-bootstrap-automation/scripts`:通过
- `.\scripts\run_demo.ps1`:通过
- dry-run 生成 7 个 gitlink-cli 调用计划,满足赛题要求
- `.\scripts\run_demo.ps1 -Config examples\verification_comment_config.json -Apply -PublishIssueNumber 4`通过3 个真实 gitlink-cli 调用状态均为 `ok`
## 交付内容
- `README.md`、`docs/`、`scripts/`、`examples/` 均位于本目录。
- `outputs/` 为运行时生成目录,评审可通过复现脚本重新生成。
- `examples/demo_outputs/` 用于保存固定示例产物。

View File

@ -0,0 +1,84 @@
# 验证记录
## 本地验证
执行目录:
```text
examples/workflows/project-bootstrap-automation
```
单元测试:
```powershell
go test ./scripts
```
结果:
```text
ok github.com/gitlink-org/gitlink-cli/examples/workflows/project-bootstrap-automation/scripts
```
dry-run 复现:
```powershell
.\scripts\run_demo.ps1
```
结果:
```text
已生成初始化报告: outputs\puygob236_gitlink-bootstrap-demo_20260524_072107_bootstrap_report.md
已生成初始化摘要: outputs\puygob236_gitlink-bootstrap-demo_20260524_072107_summary.md
已生成文件清单: outputs\puygob236_gitlink-bootstrap-demo_20260524_072107_manifest.json
已生成命令日志: outputs\command_log_20260524_072107.json
模式: dry-run
计划/执行 gitlink-cli 调用: 7 个
```
## 真实仓库验证计划
目标仓库:
```text
puygob236/gitlink-bootstrap-demo
```
验证步骤:
1. 确认 GitLink 认证可用。
2. 创建或确认目标仓库存在。
3. 执行 `.\scripts\run_demo.ps1 -Apply`
4. 检查分支、Issue 和输出报告。
5. 如需展示回写能力,执行 `.\scripts\run_demo.ps1 -Apply -PublishIssueNumber <number>`
## 真实仓库验证结果
目标仓库:
```text
https://gitlink.org.cn/puygob236/gitlink-bootstrap-demo
```
已完成验证:
- `repo +info`:成功读取 `puygob236/gitlink-bootstrap-demo` 仓库信息。
- `branch +list`:成功读取 `master`、`develop`、`release/v0.1` 分支。
- `issue +create`:成功创建初始化 Issue生成项目任务清单。
- `issue +comment`:成功将初始化摘要回写到 Issue。
回写验证命令:
```powershell
.\scripts\run_demo.ps1 -Config examples\verification_comment_config.json -Apply -PublishIssueNumber 4
```
回写验证结果:
```text
模式: apply
计划/执行 gitlink-cli 调用: 3 个
```
命令日志中 3 条调用状态均为 `ok`,无 stderr。

View File

@ -0,0 +1,17 @@
# 示例输出
本目录保存 `scripts/bootstrap_project.go` 在 dry-run 模式下生成的固定示例产物,便于快速查看工作流输出格式。
生成命令:
```powershell
go run scripts\bootstrap_project.go --config examples\sample_project.json --output-dir examples\demo_outputs --now 2026-05-24T08:00:00Z
```
产物说明:
- `*_bootstrap_report.md`:项目初始化报告
- `*_summary.md`:可发布到 Issue 的初始化摘要
- `*_manifest.json`:结构化初始化清单
- `*_files.json`:生成文件内容包
- `command_log_*.json`gitlink-cli 命令计划

View File

@ -0,0 +1,144 @@
{
"mode": "dry-run",
"commands": [
{
"command": [
"gitlink-cli",
"repo",
"+info",
"--owner",
"puygob236",
"--repo",
"gitlink-bootstrap-demo",
"--format",
"json"
],
"status": "planned",
"returncode": null,
"stdout": "",
"stderr": ""
},
{
"command": [
"gitlink-cli",
"branch",
"+list",
"--owner",
"puygob236",
"--repo",
"gitlink-bootstrap-demo",
"--format",
"json"
],
"status": "planned",
"returncode": null,
"stdout": "",
"stderr": ""
},
{
"command": [
"gitlink-cli",
"branch",
"+create",
"--owner",
"puygob236",
"--repo",
"gitlink-bootstrap-demo",
"--name",
"develop",
"--from",
"master",
"--format",
"json"
],
"status": "planned",
"returncode": null,
"stdout": "",
"stderr": ""
},
{
"command": [
"gitlink-cli",
"branch",
"+create",
"--owner",
"puygob236",
"--repo",
"gitlink-bootstrap-demo",
"--name",
"release/v0.1",
"--from",
"master",
"--format",
"json"
],
"status": "planned",
"returncode": null,
"stdout": "",
"stderr": ""
},
{
"command": [
"gitlink-cli",
"issue",
"+create",
"--owner",
"puygob236",
"--repo",
"gitlink-bootstrap-demo",
"--title",
"完善项目 README 与快速开始文档",
"--body",
"仓库: `puygob236/gitlink-bootstrap-demo`\n\n类型: documentation\n优先级: normal\n\n## 任务清单\n\n- [ ] 补充项目背景和目标用户\n- [ ] 补充安装与运行步骤\n- [ ] 补充最小示例\n\n## 验收标准\n\nREADME 能支撑新贡献者在 10 分钟内完成本地启动。\n",
"--format",
"json"
],
"status": "planned",
"returncode": null,
"stdout": "",
"stderr": ""
},
{
"command": [
"gitlink-cli",
"issue",
"+create",
"--owner",
"puygob236",
"--repo",
"gitlink-bootstrap-demo",
"--title",
"建立基础 CI 检查",
"--body",
"仓库: `puygob236/gitlink-bootstrap-demo`\n\n类型: ci\n优先级: high\n\n## 任务清单\n\n- [ ] 添加测试命令\n- [ ] 添加 lint 或格式检查\n- [ ] 在 PR 中展示检查结果\n\n## 验收标准\n\n每次 push 和 PR 均能触发基础检查。\n",
"--format",
"json"
],
"status": "planned",
"returncode": null,
"stdout": "",
"stderr": ""
},
{
"command": [
"gitlink-cli",
"issue",
"+create",
"--owner",
"puygob236",
"--repo",
"gitlink-bootstrap-demo",
"--title",
"规划 v0.1 版本里程碑",
"--body",
"仓库: `puygob236/gitlink-bootstrap-demo`\n\n类型: release\n优先级: normal\n\n## 任务清单\n\n- [ ] 整理 v0.1 范围\n- [ ] 确定验收标准\n- [ ] 准备 Release Notes 模板\n\n## 验收标准\n\n形成可执行的 v0.1 版本任务列表。\n",
"--format",
"json"
],
"status": "planned",
"returncode": null,
"stdout": "",
"stderr": ""
}
]
}

View File

@ -0,0 +1,41 @@
# GitLink 项目初始化工作流报告
## 目标项目
- 仓库: `puygob236/gitlink-bootstrap-demo`
- 项目名称: Open Research Toolkit
- 描述: A reproducible GitLink project initialized by an end-to-end automation workflow.
- 生成时间: 2026-05-24T08:00:00Z
## 初始化文件
| 文件 | 字节数 |
| --- | ---: |
| `README.md` | 545 |
| `LICENSE` | 179 |
| `.github/workflows/ci.yml` | 232 |
| `docs/CONTRIBUTING.md` | 83 |
| `docs/ROADMAP.md` | 92 |
## 分支计划
| 分支 | 来源 | 保护 |
| --- | --- | --- |
| `develop` | `master` | false |
| `release/v0.1` | `master` | false |
## 初始 Issue 计划
| 序号 | 标题 | 优先级 |
| ---: | --- | --- |
| 1 | 完善项目 README 与快速开始文档 | normal |
| 2 | 建立基础 CI 检查 | high |
| 3 | 规划 v0.1 版本里程碑 | normal |
## 工作流闭环
1. 读取项目配置。
2. 生成 README、LICENSE、CI 和协作文档。
3. 调用 gitlink-cli 检查仓库和分支状态。
4. 调用 gitlink-cli 创建初始化 Issue。
5. 输出报告、摘要和结构化 manifest必要时回写到 GitLink Issue。

View File

@ -0,0 +1,7 @@
{
".github/workflows/ci.yml": "name: Go CI\n\non:\n push:\n pull_request:\n\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n - uses: actions/setup-go@v5\n with:\n go-version: \"1.23\"\n - run: go test ./...\n",
"LICENSE": "# License\n\nThis project is initialized with the `MulanPSL-2.0` license.\n\nThe final repository should keep the complete license text that matches the selected open-source license.\n",
"README.md": "# Open Research Toolkit\n\nA reproducible GitLink project initialized by an end-to-end automation workflow.\n\n## 项目信息\n\n- GitLink 仓库: `puygob236/gitlink-bootstrap-demo`\n- 技术方向: Go\n- 初始化来源: GitLink 项目一键初始化工作流\n\n## 快速开始\n\n```bash\ngit clone https://gitlink.org.cn/puygob236/gitlink-bootstrap-demo.git\ncd gitlink-bootstrap-demo\n```\n\n## 协作约定\n\n- 使用 Issue 跟踪需求、缺陷和文档任务。\n- 使用 Pull Request 合并代码变更。\n- 重要里程碑通过 Release Notes 记录。\n",
"docs/CONTRIBUTING.md": "# 贡献指南\n\n请通过 Issue 讨论需求,通过 Pull Request 提交变更。\n",
"docs/ROADMAP.md": "# Roadmap\n\n- [ ] 完成项目初始化\n- [ ] 建立基础测试\n- [ ] 发布第一个版本\n"
}

View File

@ -0,0 +1,81 @@
{
"repository": "puygob236/gitlink-bootstrap-demo",
"project": {
"name": "Open Research Toolkit",
"description": "A reproducible GitLink project initialized by an end-to-end automation workflow.",
"language": "Go",
"license": "MulanPSL-2.0"
},
"files": [
{
"path": "README.md",
"bytes": 545
},
{
"path": "LICENSE",
"bytes": 179
},
{
"path": ".github/workflows/ci.yml",
"bytes": 232
},
{
"path": "docs/CONTRIBUTING.md",
"bytes": 83
},
{
"path": "docs/ROADMAP.md",
"bytes": 92
}
],
"branches": [
{
"name": "develop",
"from": "master",
"create": true,
"protect": false
},
{
"name": "release/v0.1",
"from": "master",
"create": true,
"protect": false
}
],
"issues": [
{
"title": "完善项目 README 与快速开始文档",
"type": "documentation",
"priority": "normal",
"tasks": [
"补充项目背景和目标用户",
"补充安装与运行步骤",
"补充最小示例"
],
"acceptance": "README 能支撑新贡献者在 10 分钟内完成本地启动。"
},
{
"title": "建立基础 CI 检查",
"type": "ci",
"priority": "high",
"tasks": [
"添加测试命令",
"添加 lint 或格式检查",
"在 PR 中展示检查结果"
],
"acceptance": "每次 push 和 PR 均能触发基础检查。"
},
{
"title": "规划 v0.1 版本里程碑",
"type": "release",
"priority": "normal",
"tasks": [
"整理 v0.1 范围",
"确定验收标准",
"准备 Release Notes 模板"
],
"acceptance": "形成可执行的 v0.1 版本任务列表。"
}
],
"generated_at": "2026-05-24T08:00:00Z"
}

View File

@ -0,0 +1,8 @@
# GitLink 项目初始化摘要
- 目标仓库: `puygob236/gitlink-bootstrap-demo`
- 项目名称: Open Research Toolkit
- 生成时间: 2026-05-24T08:00:00Z
- 初始化文件: 5 个
- 初始 Issue: 3 个
- 分支动作: 2 个

View File

@ -0,0 +1,64 @@
{
"project": {
"name": "Open Research Toolkit",
"description": "A reproducible GitLink project initialized by an end-to-end automation workflow.",
"language": "Go",
"license": "MulanPSL-2.0"
},
"repository": {
"owner": "puygob236",
"name": "gitlink-bootstrap-demo"
},
"branches": [
{
"name": "develop",
"from": "master",
"create": true,
"protect": false
},
{
"name": "release/v0.1",
"from": "master",
"create": true,
"protect": false
}
],
"issues": [
{
"title": "完善项目 README 与快速开始文档",
"type": "documentation",
"priority": "normal",
"tasks": [
"补充项目背景和目标用户",
"补充安装与运行步骤",
"补充最小示例"
],
"acceptance": "README 能支撑新贡献者在 10 分钟内完成本地启动。"
},
{
"title": "建立基础 CI 检查",
"type": "ci",
"priority": "high",
"tasks": [
"添加测试命令",
"添加 lint 或格式检查",
"在 PR 中展示检查结果"
],
"acceptance": "每次 push 和 PR 均能触发基础检查。"
},
{
"title": "规划 v0.1 版本里程碑",
"type": "release",
"priority": "normal",
"tasks": [
"整理 v0.1 范围",
"确定验收标准",
"准备 Release Notes 模板"
],
"acceptance": "形成可执行的 v0.1 版本任务列表。"
}
],
"publish": {
"issue_number": 0
}
}

View File

@ -0,0 +1,17 @@
{
"project": {
"name": "Open Research Toolkit",
"description": "A reproducible GitLink project initialized by an end-to-end automation workflow.",
"language": "Go",
"license": "MulanPSL-2.0"
},
"repository": {
"owner": "puygob236",
"name": "gitlink-bootstrap-demo"
},
"branches": [],
"issues": [],
"publish": {
"issue_number": 4
}
}

View File

@ -0,0 +1,478 @@
package main
import (
"bytes"
"encoding/json"
"errors"
"flag"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
)
type ProjectConfig struct {
Project ProjectInfo `json:"project"`
Repository RepositoryInfo `json:"repository"`
Branches []BranchPlan `json:"branches"`
Issues []IssuePlan `json:"issues"`
Publish PublishConfig `json:"publish"`
}
type ProjectInfo struct {
Name string `json:"name"`
Description string `json:"description"`
Language string `json:"language"`
License string `json:"license"`
}
type RepositoryInfo struct {
Owner string `json:"owner"`
Name string `json:"name"`
}
type BranchPlan struct {
Name string `json:"name"`
From string `json:"from"`
Create *bool `json:"create"`
Protect bool `json:"protect"`
}
type IssuePlan struct {
Title string `json:"title"`
Type string `json:"type"`
Priority string `json:"priority"`
Tasks []string `json:"tasks"`
Acceptance string `json:"acceptance"`
}
type PublishConfig struct {
IssueNumber int `json:"issue_number"`
}
type FileManifestItem struct {
Path string `json:"path"`
Bytes int `json:"bytes"`
}
type OutputManifest struct {
Repository string `json:"repository"`
Project ProjectInfo `json:"project"`
Files []FileManifestItem `json:"files"`
Branches []BranchPlan `json:"branches"`
Issues []IssuePlan `json:"issues"`
GeneratedAt string `json:"generated_at"`
}
type CommandResult struct {
Command []string `json:"command"`
Status string `json:"status"`
ReturnCode *int `json:"returncode"`
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
}
type CommandLog struct {
Mode string `json:"mode"`
Commands []CommandResult `json:"commands"`
}
type OutputPaths struct {
Report string
Summary string
Manifest string
Files string
}
type Options struct {
ConfigPath string
OutputDir string
CLIBin string
Apply bool
CreateRepo bool
PublishIssueNumber int
Now string
}
func parseFlags(args []string) Options {
var opts Options
fs := flag.NewFlagSet("bootstrap-project", flag.ExitOnError)
fs.StringVar(&opts.ConfigPath, "config", filepath.FromSlash("examples/sample_project.json"), "配置文件路径")
fs.StringVar(&opts.OutputDir, "output-dir", "outputs", "输出目录")
fs.StringVar(&opts.CLIBin, "cli-bin", firstNonEmpty(os.Getenv("GITLINK_CLI_BIN"), "gitlink-cli"), "gitlink-cli 可执行文件路径")
fs.BoolVar(&opts.Apply, "apply", false, "执行真实 GitLink 写操作")
fs.BoolVar(&opts.CreateRepo, "create-repo", false, "仓库不存在时创建仓库")
fs.IntVar(&opts.PublishIssueNumber, "publish-issue-number", 0, "把初始化摘要评论到指定 Issue")
fs.StringVar(&opts.Now, "now", "", "固定当前时间ISO8601 格式")
_ = fs.Parse(args)
return opts
}
func loadConfig(path string) (ProjectConfig, error) {
var config ProjectConfig
data, err := os.ReadFile(path)
if err != nil {
return config, fmt.Errorf("配置文件不存在: %s", path)
}
data = bytes.TrimPrefix(data, []byte{0xef, 0xbb, 0xbf})
if err := json.Unmarshal(data, &config); err != nil {
return config, err
}
return config, nil
}
func parseNow(value string) (time.Time, error) {
if strings.TrimSpace(value) == "" {
return time.Now().UTC(), nil
}
text := strings.ReplaceAll(strings.TrimSpace(value), "Z", "+00:00")
dt, err := time.Parse(time.RFC3339, text)
if err != nil {
return time.Time{}, err
}
return dt.UTC(), nil
}
func isoTime(value time.Time) string {
return value.UTC().Format(time.RFC3339)
}
func safeName(value string) string {
replacer := strings.NewReplacer("/", "_", "\\", "_", " ", "_")
return replacer.Replace(value)
}
func renderReadme(config ProjectConfig) string {
language := firstNonEmpty(config.Project.Language, "未指定")
return fmt.Sprintf("# %s\n\n%s\n\n## 项目信息\n\n- GitLink 仓库: `%s/%s`\n- 技术方向: %s\n- 初始化来源: GitLink 项目一键初始化工作流\n\n## 快速开始\n\n```bash\ngit clone https://gitlink.org.cn/%s/%s.git\ncd %s\n```\n\n## 协作约定\n\n- 使用 Issue 跟踪需求、缺陷和文档任务。\n- 使用 Pull Request 合并代码变更。\n- 重要里程碑通过 Release Notes 记录。\n",
config.Project.Name,
config.Project.Description,
config.Repository.Owner,
config.Repository.Name,
language,
config.Repository.Owner,
config.Repository.Name,
config.Repository.Name,
)
}
func renderLicense(config ProjectConfig) string {
licenseName := firstNonEmpty(config.Project.License, "MulanPSL-2.0")
return fmt.Sprintf("# License\n\nThis project is initialized with the `%s` license.\n\nThe final repository should keep the complete license text that matches the selected open-source license.\n", licenseName)
}
func renderCI(config ProjectConfig) string {
if strings.Contains(strings.ToLower(config.Project.Language), "go") {
return `name: Go CI
on:
push:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.23"
- run: go test ./...
`
}
return `name: Basic CI
on:
push:
pull_request:
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: echo "Add project-specific checks here."
`
}
func plannedFiles(config ProjectConfig) map[string]string {
return map[string]string{
"README.md": renderReadme(config),
"LICENSE": renderLicense(config),
".github/workflows/ci.yml": renderCI(config),
"docs/CONTRIBUTING.md": "# 贡献指南\n\n请通过 Issue 讨论需求,通过 Pull Request 提交变更。\n",
"docs/ROADMAP.md": "# Roadmap\n\n- [ ] 完成项目初始化\n- [ ] 建立基础测试\n- [ ] 发布第一个版本\n",
}
}
func issueBody(item IssuePlan, config ProjectConfig) string {
repo := fmt.Sprintf("%s/%s", config.Repository.Owner, config.Repository.Name)
tasks := "- [ ] 待补充"
if len(item.Tasks) > 0 {
lines := make([]string, 0, len(item.Tasks))
for _, task := range item.Tasks {
lines = append(lines, "- [ ] "+task)
}
tasks = strings.Join(lines, "\n")
}
return fmt.Sprintf("仓库: `%s`\n\n类型: %s\n优先级: %s\n\n## 任务清单\n\n%s\n\n## 验收标准\n\n%s\n",
repo,
firstNonEmpty(item.Type, "task"),
firstNonEmpty(item.Priority, "normal"),
tasks,
firstNonEmpty(item.Acceptance, "完成后在本 Issue 中说明验证结果。"),
)
}
func shouldCreateBranch(branch BranchPlan) bool {
return branch.Create == nil || *branch.Create
}
func branchFrom(branch BranchPlan) string {
return firstNonEmpty(branch.From, "master")
}
func buildCLIPlan(config ProjectConfig, summary string, createRepo bool) [][]string {
owner := config.Repository.Owner
repo := config.Repository.Name
commands := [][]string{}
if createRepo {
commands = append(commands, []string{"repo", "+create", "--name", repo, "--description", config.Project.Description, "--format", "json"})
}
commands = append(commands,
[]string{"repo", "+info", "--owner", owner, "--repo", repo, "--format", "json"},
[]string{"branch", "+list", "--owner", owner, "--repo", repo, "--format", "json"},
)
for _, branch := range config.Branches {
if shouldCreateBranch(branch) {
commands = append(commands, []string{"branch", "+create", "--owner", owner, "--repo", repo, "--name", branch.Name, "--from", branchFrom(branch), "--format", "json"})
}
if branch.Protect {
commands = append(commands, []string{"branch", "+protect", "--owner", owner, "--repo", repo, "--name", branch.Name, "--format", "json"})
}
}
for _, issue := range config.Issues {
commands = append(commands, []string{"issue", "+create", "--owner", owner, "--repo", repo, "--title", issue.Title, "--body", issueBody(issue, config), "--format", "json"})
}
if summary != "" && config.Publish.IssueNumber > 0 {
commands = append(commands, []string{"issue", "+comment", "--owner", owner, "--repo", repo, "--number", strconv.Itoa(config.Publish.IssueNumber), "--body", summary, "--format", "json"})
}
return commands
}
func runCommand(cliBin string, args []string, apply bool) CommandResult {
command := append([]string{cliBin}, args...)
if !apply {
return CommandResult{Command: command, Status: "planned"}
}
cmd := exec.Command(cliBin, args...)
if strings.HasSuffix(strings.ToLower(cliBin), ".cmd") || strings.HasSuffix(strings.ToLower(cliBin), ".bat") {
cmd = exec.Command("cmd", append([]string{"/c", cliBin}, args...)...)
}
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
returnCode := 0
status := "ok"
if err != nil {
status = "failed"
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
returnCode = exitErr.ExitCode()
} else {
returnCode = 1
}
if isIdempotentSkip(stderr.String()) {
status = "skipped"
}
}
return CommandResult{
Command: command,
Status: status,
ReturnCode: &returnCode,
Stdout: strings.TrimSpace(stdout.String()),
Stderr: strings.TrimSpace(stderr.String()),
}
}
func isIdempotentSkip(stderr string) bool {
knownMessages := []string{
"新分支已存在",
"branch already exists",
"repository already exists",
"仓库已存在",
}
for _, message := range knownMessages {
if strings.Contains(stderr, message) {
return true
}
}
return false
}
func writeOutputs(config ProjectConfig, outputDir string, now time.Time) (OutputPaths, error) {
owner := config.Repository.Owner
repo := config.Repository.Name
prefix := fmt.Sprintf("%s_%s_%s", safeName(owner), safeName(repo), now.UTC().Format("20060102_150405"))
if err := os.MkdirAll(outputDir, 0o755); err != nil {
return OutputPaths{}, err
}
files := plannedFiles(config)
fileManifest := make([]FileManifestItem, 0, len(files))
for _, path := range []string{"README.md", "LICENSE", ".github/workflows/ci.yml", "docs/CONTRIBUTING.md", "docs/ROADMAP.md"} {
if content, ok := files[path]; ok {
fileManifest = append(fileManifest, FileManifestItem{Path: path, Bytes: len([]byte(content))})
}
}
summary := fmt.Sprintf("# GitLink 项目初始化摘要\n\n- 目标仓库: `%s/%s`\n- 项目名称: %s\n- 生成时间: %s\n- 初始化文件: %d 个\n- 初始 Issue: %d 个\n- 分支动作: %d 个\n",
owner,
repo,
config.Project.Name,
isoTime(now),
len(files),
len(config.Issues),
len(config.Branches),
)
report := renderReport(config, fileManifest, now)
manifest := OutputManifest{
Repository: fmt.Sprintf("%s/%s", owner, repo),
Project: config.Project,
Files: fileManifest,
Branches: config.Branches,
Issues: config.Issues,
GeneratedAt: isoTime(now),
}
manifestJSON, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return OutputPaths{}, err
}
filesJSON, err := json.MarshalIndent(files, "", " ")
if err != nil {
return OutputPaths{}, err
}
paths := OutputPaths{
Report: filepath.Join(outputDir, prefix+"_bootstrap_report.md"),
Summary: filepath.Join(outputDir, prefix+"_summary.md"),
Manifest: filepath.Join(outputDir, prefix+"_manifest.json"),
Files: filepath.Join(outputDir, prefix+"_files.json"),
}
writes := map[string][]byte{
paths.Report: []byte(report),
paths.Summary: []byte(summary),
paths.Manifest: manifestJSON,
paths.Files: filesJSON,
}
for path, data := range writes {
if err := os.WriteFile(path, data, 0o644); err != nil {
return OutputPaths{}, err
}
}
return paths, nil
}
func renderReport(config ProjectConfig, fileManifest []FileManifestItem, now time.Time) string {
fileRows := []string{}
for _, item := range fileManifest {
fileRows = append(fileRows, fmt.Sprintf("| `%s` | %d |", item.Path, item.Bytes))
}
branchRows := []string{}
for _, item := range config.Branches {
branchRows = append(branchRows, fmt.Sprintf("| `%s` | `%s` | %t |", item.Name, branchFrom(item), item.Protect))
}
if len(branchRows) == 0 {
branchRows = append(branchRows, "| 无 | 无 | false |")
}
issueRows := []string{}
for idx, item := range config.Issues {
issueRows = append(issueRows, fmt.Sprintf("| %d | %s | %s |", idx+1, item.Title, firstNonEmpty(item.Priority, "normal")))
}
if len(issueRows) == 0 {
issueRows = append(issueRows, "| 0 | 无 | normal |")
}
return fmt.Sprintf("# GitLink 项目初始化工作流报告\n\n## 目标项目\n\n- 仓库: `%s/%s`\n- 项目名称: %s\n- 描述: %s\n- 生成时间: %s\n\n## 初始化文件\n\n| 文件 | 字节数 |\n| --- | ---: |\n%s\n\n## 分支计划\n\n| 分支 | 来源 | 保护 |\n| --- | --- | --- |\n%s\n\n## 初始 Issue 计划\n\n| 序号 | 标题 | 优先级 |\n| ---: | --- | --- |\n%s\n\n## 工作流闭环\n\n1. 读取项目配置。\n2. 生成 README、LICENSE、CI 和协作文档。\n3. 调用 gitlink-cli 检查仓库和分支状态。\n4. 调用 gitlink-cli 创建初始化 Issue。\n5. 输出报告、摘要和结构化 manifest必要时回写到 GitLink Issue。\n",
config.Repository.Owner,
config.Repository.Name,
config.Project.Name,
config.Project.Description,
isoTime(now),
strings.Join(fileRows, "\n"),
strings.Join(branchRows, "\n"),
strings.Join(issueRows, "\n"),
)
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
func main() {
opts := parseFlags(os.Args[1:])
config, err := loadConfig(opts.ConfigPath)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
now, err := parseNow(opts.Now)
if err != nil {
fmt.Fprintf(os.Stderr, "无法解析 --now 的值: %v\n", err)
os.Exit(1)
}
if opts.PublishIssueNumber > 0 {
config.Publish.IssueNumber = opts.PublishIssueNumber
}
outputPaths, err := writeOutputs(config, opts.OutputDir, now)
if err != nil {
fmt.Fprintf(os.Stderr, "写入输出失败: %v\n", err)
os.Exit(1)
}
summaryBytes, err := os.ReadFile(outputPaths.Summary)
if err != nil {
fmt.Fprintf(os.Stderr, "读取摘要失败: %v\n", err)
os.Exit(1)
}
plan := buildCLIPlan(config, string(summaryBytes), opts.CreateRepo)
results := make([]CommandResult, 0, len(plan))
for _, command := range plan {
results = append(results, runCommand(opts.CLIBin, command, opts.Apply))
}
mode := "dry-run"
if opts.Apply {
mode = "apply"
}
commandLog := CommandLog{Mode: mode, Commands: results}
commandLogJSON, err := json.MarshalIndent(commandLog, "", " ")
if err != nil {
fmt.Fprintf(os.Stderr, "生成命令日志失败: %v\n", err)
os.Exit(1)
}
commandLogPath := filepath.Join(opts.OutputDir, fmt.Sprintf("command_log_%s.json", now.UTC().Format("20060102_150405")))
if err := os.WriteFile(commandLogPath, commandLogJSON, 0o644); err != nil {
fmt.Fprintf(os.Stderr, "写入命令日志失败: %v\n", err)
os.Exit(1)
}
fmt.Printf("已生成初始化报告: %s\n", outputPaths.Report)
fmt.Printf("已生成初始化摘要: %s\n", outputPaths.Summary)
fmt.Printf("已生成文件清单: %s\n", outputPaths.Manifest)
fmt.Printf("已生成命令日志: %s\n", commandLogPath)
fmt.Printf("模式: %s\n", mode)
fmt.Printf("计划/执行 gitlink-cli 调用: %d 个\n", len(results))
failed := 0
for _, result := range results {
if result.Status == "failed" {
failed++
}
}
if failed > 0 {
fmt.Printf("失败命令: %d 个\n", failed)
os.Exit(1)
}
}

View File

@ -0,0 +1,126 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func sampleConfig() ProjectConfig {
create := true
return ProjectConfig{
Project: ProjectInfo{
Name: "Demo Project",
Description: "Demo description",
Language: "Go",
License: "MulanPSL-2.0",
},
Repository: RepositoryInfo{Owner: "alice", Name: "demo"},
Branches: []BranchPlan{{Name: "develop", From: "master", Create: &create}},
Issues: []IssuePlan{
{
Title: "Write README",
Type: "documentation",
Priority: "normal",
Tasks: []string{"Add quickstart", "Add license"},
},
},
}
}
func TestPlannedFilesIncludeRequiredProjectArtifacts(t *testing.T) {
files := plannedFiles(sampleConfig())
for _, path := range []string{"README.md", "LICENSE", ".github/workflows/ci.yml", "docs/CONTRIBUTING.md"} {
if _, ok := files[path]; !ok {
t.Fatalf("expected planned file %s", path)
}
}
}
func TestBuildCLIPlanChainsMoreThanThreeGitlinkCommands(t *testing.T) {
plan := buildCLIPlan(sampleConfig(), "", false)
if len(plan) < 4 {
t.Fatalf("expected at least 4 commands, got %d", len(plan))
}
if strings.Join(plan[0][:2], " ") != "repo +info" {
t.Fatalf("unexpected first command: %#v", plan[0])
}
if !containsCommand(plan, "branch +list") {
t.Fatalf("branch +list command missing: %#v", plan)
}
if !containsCommand(plan, "issue +create") {
t.Fatalf("issue +create command missing: %#v", plan)
}
}
func TestBuildCLIPlanCanCreateRepositoryFirst(t *testing.T) {
plan := buildCLIPlan(sampleConfig(), "", true)
if strings.Join(plan[0][:2], " ") != "repo +create" {
t.Fatalf("unexpected first command: %#v", plan[0])
}
if strings.Join(plan[1][:2], " ") != "repo +info" {
t.Fatalf("unexpected second command: %#v", plan[1])
}
}
func TestIssueBodyContainsChecklistAndRepository(t *testing.T) {
config := sampleConfig()
body := issueBody(config.Issues[0], config)
if !strings.Contains(body, "`alice/demo`") {
t.Fatalf("repository missing from body: %s", body)
}
if !strings.Contains(body, "- [ ] Add quickstart") {
t.Fatalf("checklist missing from body: %s", body)
}
}
func TestWriteOutputsCreatesReportManifestAndSummary(t *testing.T) {
tmp := t.TempDir()
paths, err := writeOutputs(sampleConfig(), tmp, time.Date(2026, 5, 24, 0, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("writeOutputs returned error: %v", err)
}
for _, path := range []string{paths.Report, paths.Summary, paths.Manifest, paths.Files} {
if _, err := os.Stat(path); err != nil {
t.Fatalf("expected output %s: %v", path, err)
}
}
data, err := os.ReadFile(paths.Manifest)
if err != nil {
t.Fatalf("read manifest: %v", err)
}
var manifest OutputManifest
if err := json.Unmarshal(data, &manifest); err != nil {
t.Fatalf("unmarshal manifest: %v", err)
}
if manifest.Repository != "alice/demo" {
t.Fatalf("unexpected repository: %s", manifest.Repository)
}
if len(manifest.Files) < 4 {
t.Fatalf("expected at least 4 files, got %d", len(manifest.Files))
}
if filepath.Base(paths.Report) == "" {
t.Fatal("report path should include filename")
}
}
func TestBranchAlreadyExistsIsIdempotentSkip(t *testing.T) {
if !isIdempotentSkip("[-1] 新分支已存在!") {
t.Fatal("expected existing branch error to be skipped")
}
if isIdempotentSkip("[401] 请登录后再操作") {
t.Fatal("auth error should not be skipped")
}
}
func containsCommand(plan [][]string, command string) bool {
for _, item := range plan {
if len(item) >= 2 && strings.Join(item[:2], " ") == command {
return true
}
}
return false
}

View File

@ -0,0 +1,38 @@
param(
[string]$Config = "examples/sample_project.json",
[string]$OutputDir = "outputs",
[switch]$Apply,
[switch]$CreateRepo,
[int]$PublishIssueNumber = 0
)
$ErrorActionPreference = "Stop"
$args = @(
"run",
"scripts\bootstrap_project.go",
"--config", $Config,
"--output-dir", $OutputDir
)
if ($Apply.IsPresent) {
$cliCandidates = npm.cmd exec --yes --package=@gitlink-ai/cli -- cmd /c where gitlink-cli 2>$null
$cliPath = $cliCandidates | Where-Object { $_ -match 'gitlink-cli\.cmd$' } | Select-Object -First 1
if (-not $cliPath) {
$cliPath = $cliCandidates | Select-Object -First 1
}
if (-not $cliPath) {
throw "未能通过 npm exec 找到 gitlink-cli"
}
$args += @("--cli-bin", $cliPath, "--apply")
}
if ($CreateRepo.IsPresent) {
$args += "--create-repo"
}
if ($PublishIssueNumber -gt 0) {
$args += @("--publish-issue-number", "$PublishIssueNumber")
}
go @args