feat(version-history): 「版本记录」页 —— 追踪远端 main 全部提交(只读 + 管理员手动同步)
build / build (push) Successful in 57s

按已确认的契约实现(口径见 docs/VERSION_HISTORY.md):

- **版本号口径(只给有证据的)**:注释 tag + tag 指向该提交 + 该提交内 CHANGELOG 顶部三者一致 ⇒ 正式版本;
  tag 之后的提交按 `<主>.<次>.<自该 tag 的提交数>` 显示开发号(与 scripts/version.py **同规则**);
  证据不足 ⇒ 「历史提交 · 短SHA」+ 原因码,**不补造版本号**。完整 SHA 是唯一身份、版本号不是唯一键
  (tag 之后两条并行分支各自的第 1 个提交都算 0.1.1 ⇒ 两条都保留,用短 SHA 区分)。
- **迁移 000046**:version_history_commits(提交记录,主键 repo_key+commit_sha)+ version_history_sync_runs
  (同步状态)。快照 = 一次同步:抓完并与 X-Total-Count 对账通过才切换;失败不切换快照、不清空历史、
  last_success_at 不动(页面显示"上次同步失败(原因)· 仍展示上次成功数据");「同步成功」≠「历史完整」。
- **只读客户端(匿名,不使用任何凭据)**:白名单 origin + 固定仓库、拒绝任意 URL 与跨主机重定向、
  响应上限 5MB / 超时 15s、仅 5xx/429 退避重试(最多 3 次);逐页抓到短页为止;每次全量扫 tag
  ⇒ 旧提交后补的 tag 会更新既有记录(不新增第二行)。
- **接口**(三个都登记进 api/openapi.yaml):GET /api/v1/build-info(viewer)、
  GET /api/v1/version-history(viewer;游标绑定快照,过期返回 409 cursor_expired;不返回伪造 total)、
  POST /api/v1/version-history/sync(admin;并发同步复用同一作业,靠 IdempotencyKey)。
- **「当前运行」只认精确匹配**:产物注入的完整 SHA 命中某条记录才标该条;开发构建显式标注"开发构建";
  没有可用标识 ⇒ "当前运行版本未知";**不拿最新 main / 最新 tag 顶替**,CI 成功不等于已部署。
- **前端**:一级菜单第 5 项(版本记录)+ 时间线页面(全部/正式/开发/历史四段筛选、正文展开、
  加载更多、760px 单列无横向溢出、提交正文与变更记录按纯文本渲染);无打 tag / 发布 / 部署入口。

新增配置(留空即未接线,页面降级):VERSION_HISTORY_GITEA_BASE_URL / _REPO / _ALLOWED_ORIGINS /
_HTTP_TIMEOUT / _MAX_PAGES。

验证:`make verify` 15/15 success(tests-go 578 通过 / 0 失败;tests-web 209 通过 / 0 失败);
新增 Go 用例 30(版本口径与图遍历 12、客户端守卫 6、同步编排 5、接口 7);前端新增 18(组件 10 + 客户端 8);
真实仓库匿名快照拉取(`VERSION_HISTORY_LIVE_BASE_URL=… go test -run TestLiveSyncAgainstRealRepository`):
抓到 113 条与 X-Total-Count 一致、v0.1.0 是注释 tag 且目标与 `git rev-parse v0.1.0^{commit}` 一致、
开发号逐个等于 `git rev-list --count v0.1.0..<sha>`(正式 1 / 开发 22 / 历史 90)。
This commit is contained in:
姚章浩
2026-10-13 19:40:19 +08:00
parent ad9c5d20d4
commit 0974d8dfa2
35 changed files with 6413 additions and 659 deletions
+9
View File
@@ -28,3 +28,12 @@ TEST_WEKNORA_POLL_MAX=60s
TEST_WEKNORA_STALLED_AFTER=30m
TEST_WEKNORA_MAX_UPLOAD_BYTES=52428800
TEST_ACCEPTANCE_POLICY_VERSION=structural-v1
# 「版本记录」页:**匿名只读**远端仓库的提交与 tag(**不使用任何凭据**)。
# 留空即"未接线"(页面降级展示、同步按钮不可用)。只允许下面批准的 origin 与固定仓库,
# 不接受任何调用方传入的 URL;`VERSION_HISTORY_REPO` 必须是 owner/name。
VERSION_HISTORY_GITEA_BASE_URL=
VERSION_HISTORY_REPO=Weknora/knowledge-governance
VERSION_HISTORY_ALLOWED_ORIGINS=
VERSION_HISTORY_HTTP_TIMEOUT=15s
VERSION_HISTORY_MAX_PAGES=200
+14
View File
@@ -1,5 +1,19 @@
# Changelog
## [Unreleased]
### Added
- **「版本记录」页**(一级菜单第 5 项):只读时间线追踪**远端 main 可达的全部提交**(含合并提交与已并入 main 的侧分支提交),按提交时间倒序显示标题/正文、`[正式版本] / [开发版本] / [历史提交]` 徽标、短 SHA、提交内变更记录与 `[查看提交]`/`[查看 tag]`;支持「全部 / 正式 / 开发 / 历史」筛选与游标翻页,窄屏单列。
- 新增只读接口 `GET /api/v1/build-info`(当前运行产物的构建身份)、`GET /api/v1/version-history`(快照游标分页,不返回伪造的 total)、`POST /api/v1/version-history/sync`(仅管理员手动同步,并发同步复用同一作业)。
- 新增迁移 `000046_version_history`(提交记录 + 同步状态):`commit_sha` 是唯一身份,**版本号不是唯一键** —— 两个提交算出同一个开发号时各占一行,用 SHA 区分。
- 「当前运行」只认运行产物注入的**完整 SHA 精确匹配**:匹配到就标该条;开发构建显式标注"开发构建";拿不到标识就显示"当前运行版本未知",**不拿最新 main 或最新 tag 顶替**;CI 成功不等于已部署。
### Security
- 版本记录同步**匿名只读**远端仓库(**不使用任何凭据**),来源限配置里批准的 origin 与固定仓库:拒绝任意 URL、拒绝跨主机重定向,单个响应设大小上限与超时、5xx/429 才有退避重试。
- 提交正文与变更记录一律按**纯文本**渲染(不产生 HTML/脚本注入面);页面不含打 tag / 发布 / 部署入口;同步动作仅管理员可用,服务端再次判角色。
> **版本号的唯一来源就是本文件顶部那条已发布版本。** 不放 `VERSION` 文件、不把版本写进源码常量。
>
> **正式版本**用**注释 tag** `vX.Y.Z`(由人批准后创建):CI 只**核验**「tag 的版本 == 它指到那个提交里的
+100
View File
@@ -44,6 +44,106 @@
}
],
"paths": {
"/api/v1/build-info": {
"get": {
"tags": [
"Runtime"
],
"operationId": "getBuildInfo",
"summary": "读取当前运行产物的构建身份(正式版本/构建类型/完整提交 SHA);拿不到注入就明确返回不可用",
"responses": {
"200": {
"$ref": "#/components/responses/Envelope"
},
"400": {
"$ref": "#/components/responses/BadRequest"
},
"500": {
"$ref": "#/components/responses/InternalError"
}
}
}
},
"/api/v1/version-history": {
"get": {
"tags": [
"Runtime"
],
"operationId": "listVersionHistory",
"summary": "按快照游标分页列出远端 main 可达的提交、版本号证据与「当前运行」判据",
"parameters": [
{
"name": "kind",
"in": "query",
"required": false,
"schema": {
"type": "string",
"enum": [
"all",
"release",
"development",
"historical"
]
}
},
{
"name": "limit",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"minimum": 1,
"maximum": 100
}
},
{
"name": "cursor",
"in": "query",
"required": false,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"$ref": "#/components/responses/Envelope"
},
"400": {
"$ref": "#/components/responses/BadRequest"
},
"409": {
"description": "游标指向的记录快照已被清理,需要刷新后重新加载"
},
"500": {
"$ref": "#/components/responses/InternalError"
}
}
}
},
"/api/v1/version-history/sync": {
"post": {
"tags": [
"Runtime"
],
"operationId": "syncVersionHistory",
"summary": "手动同步一次版本记录(仅管理员;并发同步复用同一个作业)",
"responses": {
"200": {
"$ref": "#/components/responses/Envelope"
},
"202": {
"description": "已入队同步任务"
},
"400": {
"$ref": "#/components/responses/BadRequest"
},
"500": {
"$ref": "#/components/responses/InternalError"
}
}
}
},
"/api/v1/backup-schedule": {
"get": {
"tags": [
+9 -2
View File
@@ -120,6 +120,11 @@ func run(ctx context.Context, getenv func(string) string) error {
TestAllowDockerHTTP: config.TestWeKnoraAllowDockerHTTP,
ProductionAllowDockerHTTP: config.ProductionWeKnoraAllowDockerHTTP,
})
// 「版本记录」:只读查询面在 API 侧接线;真正的抓取在 worker 侧(未配置来源即未接线)。
versionHistoryStore, versionHistoryRepo := httpapi.VersionHistoryStore(nil), ""
if config.VersionHistoryGiteaBaseURL != "" {
versionHistoryStore, versionHistoryRepo = postgresstore.NewStore(pool), config.VersionHistoryRepo
}
handler, err := httpapi.NewHandler(httpapi.Config{
Store: postgresstore.NewStore(pool),
Monitor: monitorProvider,
@@ -137,8 +142,10 @@ func run(ctx context.Context, getenv func(string) string) error {
ProductionReleaseAuthorizerRef: config.ProductionReleaseAuthorizerRef,
ProductionReleaseAuthorizationToken: releaseAuthorizationToken,
// 数据源发现(系统设置 → 数据源配置):用与 worker 同一份 dws 状态做只读列举。
DingtalkSources: dingtalkSourceLister{},
BackupSchedule: postgresstore.NewStore(pool),
DingtalkSources: dingtalkSourceLister{},
BackupSchedule: postgresstore.NewStore(pool),
VersionHistory: versionHistoryStore,
VersionHistoryRepo: versionHistoryRepo,
BackupHost: backuphost.New(backuphost.Config{
SSHConfig: config.BackupHostSSHConfig,
Target: config.BackupHostTarget,
+37
View File
@@ -2,6 +2,7 @@ package main
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
@@ -16,6 +17,7 @@ import (
"knowledge-governance/internal/jobs"
postgresstore "knowledge-governance/internal/platform/postgres"
"knowledge-governance/internal/platform/weknora"
"knowledge-governance/internal/versionhistory"
artifactfetch "knowledge-governance/internal/workflows/artifactfetch"
catalogrefresh "knowledge-governance/internal/workflows/catalogrefresh"
"knowledge-governance/internal/workflows/sourcescan"
@@ -110,6 +112,41 @@ func workerHandlers(store *postgresstore.Store, config appconfig.Config) (map[st
return nil, fmt.Errorf("create artifact fetch handler: %w", err)
}
handlers["artifact.fetch"] = artifactHandler.Handle
if config.VersionHistoryGiteaBaseURL != "" {
// 「版本记录」同步:匿名只读远端仓库的提交/tag,写本库快照。未配置来源就不注册,
// 界面上的同步按钮也会显示"未接线",不会入队一个没人处理的作业。
client, err := versionhistory.NewGiteaClient(versionhistory.GiteaConfig{
BaseURL: config.VersionHistoryGiteaBaseURL,
AllowedBaseURLs: config.VersionHistoryAllowedOrigins,
Repo: config.VersionHistoryRepo,
Timeout: config.VersionHistoryHTTPTimeout,
})
if err != nil {
return nil, fmt.Errorf("create version history client: %w", err)
}
syncer, err := versionhistory.NewSyncer(versionhistory.SyncerConfig{Source: client, Store: store,
RepoKey: config.VersionHistoryRepo, MaxPages: config.VersionHistoryMaxPages})
if err != nil {
return nil, fmt.Errorf("create version history syncer: %w", err)
}
handlers[versionhistory.JobType] = func(ctx context.Context, job jobs.Job) error {
triggeredBy := ""
var payload struct {
TriggeredBy string `json:"triggered_by"`
}
if err := json.Unmarshal(job.Payload, &payload); err == nil {
triggeredBy = payload.TriggeredBy
}
run, err := syncer.Sync(ctx, job.ID, triggeredBy)
if err != nil {
log.Printf("version history sync failed job=%s run=%d code=%s: %v", job.ID, run.ID, run.ErrorCode, err)
return err
}
log.Printf("version history sync completed job=%s run=%d fetched=%d expected=%v complete=%v",
job.ID, run.ID, run.FetchedCount, run.ExpectedCount, run.Complete)
return nil
}
}
if config.PPScanEnabled {
scanner, err := sourcescan.NewFilePPScanner(sourcescan.FilePPScannerConfig{
AllowedRoot: config.PPScanAllowedRoot,
+80
View File
@@ -0,0 +1,80 @@
# 版本记录(全部 main 提交时间线)
「版本记录」是平台一级菜单的只读页面:追踪**远端 main 可达的每一个提交**(含合并提交与已并入 main
的侧分支提交),按提交时间倒序展示,并标注它对应哪一版、有没有正式 tag、是不是当前正在跑的那份产物。
## 一、版本号口径(只给有证据的号)
| 判定 | 条件(全部可机器核验) | 页面显示 |
|---|---|---|
| **正式版本** | ① 有 tag `vX.Y.Z` 指向该提交;② 该 tag 是**注释 tag**(Gitea `/git/refs/tags` 的 `object.type == "tag"`);③ 该提交内 `CHANGELOG.md` 顶部版本 == `X.Y.Z` | `0.1.0` + `[正式版本]`,正文取该提交内的变更记录 |
| **开发版本** | 该提交内 CHANGELOG 顶部有版本号,且存在 `v<该版本>` tag、能算出「自该 tag 以来的提交数」n | `<主>.<次>.<n>` + `[开发版本]`(与 `scripts/version.py` **同规则**:n=0 时等于版本号本身) |
| **历史提交** | 证据不足(轻量 tag / tag 与提交内记录不一致 / 找不到基线 / 提交图不完整) | `历史提交 · <短SHA>` + 具体原因,**不补造版本号** |
- **完整 SHA 是唯一身份**,版本号不是唯一键:两个提交算出同一个号(例如 tag 之后两个并行分支各自第 1 个提交都是 `0.1.1`)时**两条都保留**,用短 SHA 区分。
- 提交数按提交图算(等价 `git rev-list --count <tag>..<sha>`),**不执行历史提交里的任何脚本**;提交图不完整(检出被截断)时拒绝给号,原因码 `shallow_or_incomplete_graph`。
## 二、数据与同步
- 两张表(迁移 `000046_version_history`):`version_history_commits`(提交记录,主键 `repo_key + commit_sha`)、`version_history_sync_runs`(同步状态)。
- **快照 = 一次同步**(`snapshot_id = sync_runs.id`):抓取期间旧快照继续对外可见;抓完并**与 `X-Total-Count` 对账通过**后才成为事实。失败**不切换快照**、不清空历史,`last_success_at` 保持不动 ⇒ 页面显示"上次同步失败(原因)· 仍展示上次成功的数据(时间)"。
- **同步成功 ≠ 历史完整**:`complete=false` 时页面显示"部分历史(N/M)"。
- 抓取方式:`GET /repos/{owner}/{repo}/commits?limit=50&page=N` 逐页到短页为止(本仓 113 条 = 3 页);**每次同步都全量扫 tag**,因此"旧提交后补的 tag"会更新到既有记录(不新增第二行)。
- 游标绑定快照(`base64(snapshot_id|committed_at|sha)`),排序 `committed_at DESC, commit_sha DESC` ⇒ 翻页确定性、不重不漏;游标指向的快照被清理后返回 `409 cursor_expired`,页面提示刷新。
- 只保留最近 **3** 个成功快照供游标使用(`VersionHistoryKeptSnapshots`)。
## 三、当前运行(只认精确匹配)
- `GET /api/v1/build-info` 回运行产物由 `-ldflags -X` 注入的身份:`release_version / build_kind / commit_sha(完整 40 位)/ display_version / built_at / identity_available`。
- 页面判据:完整 SHA **精确命中**某条记录 ⇒ 标该条;`build_kind=development` 且命中不了 ⇒ 顶部显示"开发构建 `<号>`(短SHA)",**不标任何条目**;没有可用标识 ⇒ "当前运行版本未知"。
- **绝不**用"最新 main"或"最新 tag"顶替当前运行;**CI 成功不等于已部署**。
## 四、配置(匿名只读,不使用凭据)
```text
VERSION_HISTORY_GITEA_BASE_URL=http://192.168.50.223:3000 # 留空 = 未接线(页面降级,同步按钮不可用)
VERSION_HISTORY_REPO=Weknora/knowledge-governance # 固定 owner/name
VERSION_HISTORY_ALLOWED_ORIGINS= # 逗号分隔白名单;留空 = 只允许上面那个 origin
VERSION_HISTORY_HTTP_TIMEOUT=15s
VERSION_HISTORY_MAX_PAGES=200
```
安全边界:来源必须落在白名单 origin 与固定仓库内(**拒绝任意 URL**)、拒绝跨主机重定向、单响应大小上限 5MB、
超时 15s、仅 5xx/429 退避重试(最多 3 次);**请求不携带任何凭据**;提交正文与变更记录一律按**纯文本**渲染。
## 五、接口与权限
| 接口 | 最小角色 | 说明 |
|---|---|---|
| `GET /api/v1/build-info` | viewer | 当前运行产物的构建身份(只回身份字段) |
| `GET /api/v1/version-history` | viewer | 游标分页列表;带 `sync` 状态与 `current_running` 判定 |
| `POST /api/v1/version-history/sync` | **admin** | 手动同步一次;并发同步**复用同一个作业**(`IdempotencyKey`) |
三个接口都登记在 `api/openapi.yaml`(契约测试会核对)。
## 六、同步触发
初版只有**管理员手动同步**(页面上「同步记录」按钮,仅管理员可见);**不默认新增定时调度**。
## 七、验证
```bash
# 单元与规则(含分页去重、对账失败、不可达保留旧快照、后补 tag 升级、同号不同 SHA)
go test ./internal/versionhistory/ ./internal/governance/ ./internal/httpapi/ -count=1
# 真实仓库交叉核对(**匿名**;未设变量即按设计跳过,理由在 scripts/test-scope.json)
VERSION_HISTORY_LIVE_BASE_URL=http://192.168.50.223:3000 \
go test ./internal/versionhistory/ -run TestLiveSyncAgainstRealRepository -count=1 -v
# 全量门禁
make verify
```
真实仓库交叉核对会断言:抓到条数 == `X-Total-Count`、SHA 不重复、`vX.Y.Z` 是注释 tag 且目标与
`git rev-parse vX.Y.Z^{commit}` 一致、开发号与本地 `git rev-list --count vX.Y.Z..<sha>` 逐个相等。
## 八、不做什么
- 不提供打 tag / 发布 / 部署入口;同步不改远端任何东西(只读 GET)。
- 不用最新 tag 给历史提交重新编号,不给证据不足的提交编版本号。
- 不把「软件版本」与「知识发布批次」混在一起(发布批次在「发布管理」)。
+58 -4
View File
@@ -61,6 +61,14 @@ type Config struct {
BackupHostTarget string
BackupHostScriptDir string
BackupHostRoot string
// 版本记录(「版本记录」页):**匿名只读**远端仓库的提交与 tag。来源留空即"未接线"
// (页面降级展示,接口返回可用性=false)。只允许配置里批准的 origin 与仓库,
// 不接受任何调用方传入的 URL;本功能**不使用凭据**。
VersionHistoryGiteaBaseURL string
VersionHistoryRepo string
VersionHistoryAllowedOrigins []string
VersionHistoryHTTPTimeout time.Duration
VersionHistoryMaxPages int
}
func Load(getenv func(string) string) (Config, error) {
@@ -98,10 +106,15 @@ func Load(getenv func(string) string) (Config, error) {
TestAcceptancePolicyVersion: valueOrDefault(getenv("TEST_ACCEPTANCE_POLICY_VERSION"), "structural-v1"),
TestAutoReviewPolicy: valueOrDefault(getenv("TEST_AUTO_REVIEW_POLICY"), string(governance.AutoReviewInternal)),
// 整库逻辑备份的目标主机:三项同时给出才接线(脚本目录/备份根有默认值)。
BackupHostSSHConfig: strings.TrimSpace(getenv("BACKUP_HOST_SSH_CONFIG")),
BackupHostTarget: strings.TrimSpace(getenv("BACKUP_HOST_TARGET")),
BackupHostScriptDir: valueOrDefault(strings.TrimSpace(getenv("BACKUP_HOST_SCRIPT_DIR")), "/opt/weknora-test"),
BackupHostRoot: valueOrDefault(strings.TrimSpace(getenv("BACKUP_HOST_ROOT")), "/srv/kb-backups"),
BackupHostSSHConfig: strings.TrimSpace(getenv("BACKUP_HOST_SSH_CONFIG")),
BackupHostTarget: strings.TrimSpace(getenv("BACKUP_HOST_TARGET")),
BackupHostScriptDir: valueOrDefault(strings.TrimSpace(getenv("BACKUP_HOST_SCRIPT_DIR")), "/opt/weknora-test"),
BackupHostRoot: valueOrDefault(strings.TrimSpace(getenv("BACKUP_HOST_ROOT")), "/srv/kb-backups"),
VersionHistoryGiteaBaseURL: strings.TrimSpace(getenv("VERSION_HISTORY_GITEA_BASE_URL")),
VersionHistoryRepo: valueOrDefault(strings.TrimSpace(getenv("VERSION_HISTORY_REPO")), "Weknora/knowledge-governance"),
VersionHistoryAllowedOrigins: splitOrigins(getenv("VERSION_HISTORY_ALLOWED_ORIGINS")),
VersionHistoryHTTPTimeout: 15 * time.Second,
VersionHistoryMaxPages: valueOrDefaultInt(getenv("VERSION_HISTORY_MAX_PAGES"), 200),
}
if config.DatabaseURL == "" {
return Config{}, errors.New("DATABASE_URL is required")
@@ -183,6 +196,26 @@ func Load(getenv func(string) string) (Config, error) {
if config.WorkerLivenessStaleAfter, err = durationOrDefault(getenv("WORKER_LIVENESS_STALE_AFTER"), config.WorkerLivenessStaleAfter); err != nil {
return Config{}, fmt.Errorf("WORKER_LIVENESS_STALE_AFTER: %w", err)
}
if config.VersionHistoryHTTPTimeout, err = durationOrDefault(getenv("VERSION_HISTORY_HTTP_TIMEOUT"), config.VersionHistoryHTTPTimeout); err != nil {
return Config{}, fmt.Errorf("VERSION_HISTORY_HTTP_TIMEOUT: %w", err)
}
// 版本记录来源:给了 origin 就必须是干净的 http(s) origin;白名单留空则默认只许自己。
if config.VersionHistoryGiteaBaseURL != "" {
if err := validateHTTPBaseURL(config.VersionHistoryGiteaBaseURL); err != nil {
return Config{}, fmt.Errorf("VERSION_HISTORY_GITEA_BASE_URL: %w", err)
}
if len(config.VersionHistoryAllowedOrigins) == 0 {
config.VersionHistoryAllowedOrigins = []string{config.VersionHistoryGiteaBaseURL}
}
for _, origin := range config.VersionHistoryAllowedOrigins {
if err := validateHTTPBaseURL(origin); err != nil {
return Config{}, fmt.Errorf("VERSION_HISTORY_ALLOWED_ORIGINS: %w", err)
}
}
if strings.Count(config.VersionHistoryRepo, "/") != 1 {
return Config{}, errors.New("VERSION_HISTORY_REPO 必须是 owner/name 形式")
}
}
if config.TestWeKnoraHTTPTimeout, err = durationOrDefault(getenv("TEST_WEKNORA_HTTP_TIMEOUT"), config.TestWeKnoraHTTPTimeout); err != nil {
return Config{}, fmt.Errorf("TEST_WEKNORA_HTTP_TIMEOUT: %w", err)
}
@@ -279,6 +312,27 @@ func validateHTTPBaseURL(value string) error {
return nil
}
// splitOrigins 解析逗号分隔的 origin 白名单(空项丢弃)。
func splitOrigins(value string) []string {
parts := strings.Split(value, ",")
origins := make([]string, 0, len(parts))
for _, part := range parts {
if trimmed := strings.TrimSpace(part); trimmed != "" {
origins = append(origins, trimmed)
}
}
return origins
}
// valueOrDefaultInt 解析正整数(非法/缺省都退回默认值)。
func valueOrDefaultInt(value string, fallback int) int {
parsed, err := strconv.Atoi(strings.TrimSpace(value))
if err != nil || parsed <= 0 {
return fallback
}
return parsed
}
func valueOrDefault(value, fallback string) string {
if value == "" {
return fallback
+268
View File
@@ -0,0 +1,268 @@
package governance
import (
"fmt"
"regexp"
"sort"
"strconv"
"strings"
"time"
)
// 版本记录(「版本记录」页)的领域类型与**版本号口径**。
//
// 口径(主人 2026-10-13 定:只给有证据的版本号):
// - 正式版本:存在**注释 tag** `vX.Y.Z` 指向该提交,且该提交内 CHANGELOG 顶部版本 == X.Y.Z;
// - 开发版本:该提交位于某个正式 tag 之后,且能算出「自该 tag 以来的提交数」⇒ `<主>.<次>.<n>`;
// - 历史提交:证据不足 ⇒ 不下发数字版本号,只给原因(**不补造**)。
//
// 不变量:**完整 SHA 是唯一身份**,版本号不是唯一键;两个提交算出同一个号时两条都保留。
const (
VersionStateVerified = "verified"
VersionStateUnknown = "unknown"
RecordKindRelease = "release"
RecordKindDevelopment = "development"
RecordKindHistorical = "historical"
// 原因码:页面据此显示「版本号未能核验」的具体理由。
ReasonAnnotatedTagMatch = "annotated_tag_and_changelog_top_match"
ReasonLightweightTag = "lightweight_tag"
ReasonChangelogTopMissing = "no_changelog_top"
ReasonChangelogMismatch = "changelog_top_mismatch"
ReasonNoReleaseTag = "no_release_tag_for_commit"
ReasonNoBaseline = "no_baseline"
// ReasonIncompleteGraph:提交图不完整(检出被截断)⇒ 提交数算不准 ⇒ 宁可不给号。
ReasonIncompleteGraph = "shallow_or_incomplete_graph"
)
// versionHistorySemver 只认三段十进制、无前导零(与 scripts/check_changelog.py 同口径)。
var versionHistorySemver = regexp.MustCompile(`^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$`)
// releasedHeading 匹配 CHANGELOG 里的已发布版本标题 `## [x.y.z] - …`。
var releasedHeading = regexp.MustCompile(`(?m)^##\s*\[(\d+\.\d+\.\d+)\]\s*$|^##\s*\[(\d+\.\d+\.\d+)\]\s*-`)
// VersionTagEvidence 是一个指向某提交的 tag 及其可核验属性。
type VersionTagEvidence struct {
Name string `json:"name"`
TagSHA string `json:"sha,omitempty"`
Annotated bool `json:"annotated"`
URL string `json:"url,omitempty"`
}
// VersionEvidence 是版本号判定的完整依据(进库的 jsonb,也直接给页面看)。
type VersionEvidence struct {
State string `json:"state"`
Reason string `json:"reason"`
Tags []VersionTagEvidence `json:"tags,omitempty"`
ChangelogTop string `json:"changelog_top,omitempty"`
BaseTag string `json:"base_tag,omitempty"`
CommitsSinceBase *int `json:"commits_since_base,omitempty"`
}
// VersionHistoryCommit 是一条提交记录(版本记录页的列表项)。
type VersionHistoryCommit struct {
RepoKey string
CommitSHA string
Parents []string
CommittedAt time.Time
AuthorDisplay string
Subject string
Body string
VersionLabel string
VersionState string
RecordKind string
ReleaseTags []VersionTagEvidence
ReleaseNotes string
CommitURL string
VersionEvidence VersionEvidence
SnapshotID int64
}
// VersionHistorySyncRun 是一次同步的状态(「同步成功」≠「历史完整」)。
type VersionHistorySyncRun struct {
ID int64
JobID string
Status string
SourceHeadSHA string
StartedAt time.Time
FinishedAt time.Time
LastSuccessAt time.Time
Complete bool
FetchedCount int
ExpectedCount *int
ErrorCode string
ActiveSnapshotID int64
TriggeredBy string
}
// CurrentRunningIdentity 是「当前运行」的判据结果:只认精确匹配,**不猜**。
type CurrentRunningIdentity struct {
State string `json:"state"` // matched | development | unknown
CommitSHA string `json:"commit_sha,omitempty"`
DisplayVersion string `json:"display_version,omitempty"`
MatchedCommitSHA string `json:"matched_commit_sha,omitempty"`
}
// VersionHistoryPage 是列表接口的返回体。
type VersionHistoryPage struct {
Items []VersionHistoryCommit
NextCursor string
HasMore bool
Sync VersionHistorySyncRun
CurrentRunning CurrentRunningIdentity
}
// SemverValid 判断是否为合规三段的正式版本号。
func SemverValid(value string) bool { return versionHistorySemver.MatchString(value) }
// CompareSemver 按主/次/修订比较;任一非法 ⇒ 0(调用方应先校验)。
func CompareSemver(left, right string) int {
lp, ok := semverParts(left)
if !ok {
return 0
}
rp, ok := semverParts(right)
if !ok {
return 0
}
for i := 0; i < 3; i++ {
if lp[i] != rp[i] {
if lp[i] < rp[i] {
return -1
}
return 1
}
}
return 0
}
func semverParts(value string) ([3]int, bool) {
var out [3]int
if !versionHistorySemver.MatchString(value) {
return out, false
}
for i, part := range strings.Split(value, ".") {
n, err := strconv.Atoi(part)
if err != nil {
return out, false
}
out[i] = n
}
return out, true
}
// DecideVersion 按证据判定一个提交的版本号口径。
//
// changelogTop 是该提交内 CHANGELOG 的顶部已发布版本(读不到就空);
// tagsAtCommit 是所有指向该提交的 tag(含注释标记);baseTag/commitsSinceBase
// 来自提交图遍历(最近的正式 tag 与该 tag 以来的提交数),没有就传空/nil。
func DecideVersion(changelogTop string, tagsAtCommit []VersionTagEvidence, baseTag string, commitsSinceBase *int) (label, state, kind string, evidence VersionEvidence) {
evidence = VersionEvidence{State: VersionStateUnknown, Reason: ReasonNoReleaseTag,
Tags: tagsAtCommit, ChangelogTop: changelogTop, BaseTag: baseTag, CommitsSinceBase: commitsSinceBase}
// ① 正式版本:注释 tag + 版本名合法 + 提交内 CHANGELOG 顶部一致。
annotated := make([]VersionTagEvidence, 0, len(tagsAtCommit))
semverTags := make([]VersionTagEvidence, 0, len(tagsAtCommit))
for _, tag := range tagsAtCommit {
if tag.Annotated {
annotated = append(annotated, tag)
}
if SemverValid(strings.TrimPrefix(tag.Name, "v")) {
semverTags = append(semverTags, tag)
}
}
sort.Slice(semverTags, func(i, j int) bool { return semverTags[i].Name < semverTags[j].Name })
for _, tag := range annotated {
version := strings.TrimPrefix(tag.Name, "v")
if !SemverValid(version) {
continue
}
if changelogTop != "" && changelogTop != version {
continue // 该 tag 不合规(版本与提交内记录不一致)⇒ 继续找别的
}
if changelogTop == "" {
evidence.Reason = ReasonChangelogTopMissing
continue
}
return version, VersionStateVerified, RecordKindRelease, VersionEvidence{
State: VersionStateVerified, Reason: ReasonAnnotatedTagMatch, Tags: tagsAtCommit,
ChangelogTop: changelogTop, BaseTag: "v" + version, CommitsSinceBase: commitsSinceBase}
}
// ② 开发版本:基线 tag 存在且能算出「自该 tag 以来的提交数」。
// 口径与 scripts/version.py **完全一致**:基线 = `v<提交内 CHANGELOG 顶部版本>`,
// 号 = `<主>.<次>.<n>`(n=0 时正好等于该版本号本身);两个提交算出同一个号时两条都保留。
if SemverValid(changelogTop) && baseTag != "" && commitsSinceBase != nil && *commitsSinceBase >= 0 {
parts, _ := semverParts(changelogTop)
label := fmt.Sprintf("%d.%d.%d", parts[0], parts[1], *commitsSinceBase)
return label, VersionStateVerified, RecordKindDevelopment, VersionEvidence{
State: VersionStateVerified, Reason: "counted_commits_since_base_tag", Tags: tagsAtCommit,
ChangelogTop: changelogTop, BaseTag: baseTag, CommitsSinceBase: commitsSinceBase}
}
// ③ 历史提交:证据不足 ⇒ 不给号,只给原因(不补造)。
annotatedSemver := 0
for _, tag := range annotated {
if SemverValid(strings.TrimPrefix(tag.Name, "v")) {
annotatedSemver++
}
}
switch {
case len(semverTags) > 0 && annotatedSemver == 0:
evidence.Reason = ReasonLightweightTag
case changelogTop == "":
evidence.Reason = ReasonChangelogTopMissing
case annotatedSemver > 0:
evidence.Reason = ReasonChangelogMismatch
case baseTag == "":
evidence.Reason = ReasonNoBaseline
default:
evidence.Reason = ReasonNoReleaseTag
}
return "", VersionStateUnknown, RecordKindHistorical, evidence
}
// ExtractReleaseNotes 从提交内的 CHANGELOG 文本里取某个已发布版本的整段正文。
//
// 边界口径与 scripts/check_changelog.py 一致:**任意二级标题都是边界**(否则
// `## 能力基线(未发布)` 这类标题会把后文算进上一节)。
func ExtractReleaseNotes(changelog, version string) string {
if changelog == "" || !SemverValid(version) {
return ""
}
lines := strings.Split(changelog, "\n")
start := -1
for i, line := range lines {
if m := releasedHeading.FindStringSubmatch(strings.TrimSpace(line)); m != nil {
if m[1] == version || m[2] == version {
start = i
break
}
}
}
if start < 0 {
return ""
}
var body []string
for _, line := range lines[start+1:] {
if strings.HasPrefix(line, "## ") {
break
}
body = append(body, line)
}
return strings.TrimSpace(strings.Join(body, "\n"))
}
// ChangelogTopVersion 取 CHANGELOG 顶部的已发布版本;没有 ⇒ 空串。
func ChangelogTopVersion(changelog string) string {
for _, line := range strings.Split(changelog, "\n") {
if m := releasedHeading.FindStringSubmatch(strings.TrimSpace(line)); m != nil {
if m[1] != "" {
return m[1]
}
return m[2]
}
}
return ""
}
+101
View File
@@ -0,0 +1,101 @@
package governance
import "testing"
func tag(name string, annotated bool) VersionTagEvidence {
return VersionTagEvidence{Name: name, TagSHA: "tag-" + name, Annotated: annotated}
}
func intPtr(value int) *int { return &value }
func TestDecideVersionReleaseRequiresAnnotatedTagAndChangelogTop(t *testing.T) {
cases := []struct {
name string
changelogTop string
tags []VersionTagEvidence
wantLabel string
wantKind string
wantReason string
}{
{"注释 tag + 顶部一致 ⇒ 正式版本", "0.1.0", []VersionTagEvidence{tag("v0.1.0", true)},
"0.1.0", RecordKindRelease, ReasonAnnotatedTagMatch},
{"轻量 tag 指向该提交 ⇒ 不算正式版本", "0.1.0", []VersionTagEvidence{tag("v0.1.0", false)},
"", RecordKindHistorical, ReasonLightweightTag},
{"有版本但无 tag ⇒ 不算正式版本", "0.1.0", nil, "", RecordKindHistorical, ReasonNoBaseline},
{"注释 tag 与提交内顶部不一致 ⇒ 不算正式版本", "0.1.1", []VersionTagEvidence{tag("v0.1.0", true)},
"", RecordKindHistorical, ReasonChangelogMismatch},
{"非三段版本名的 tag ⇒ 不算正式版本", "0.1.0", []VersionTagEvidence{tag("v0.1", true)},
"", RecordKindHistorical, ReasonNoBaseline},
}
for _, tc := range cases {
label, state, kind, evidence := DecideVersion(tc.changelogTop, tc.tags, "", nil)
if label != tc.wantLabel || kind != tc.wantKind {
t.Fatalf("%s:label=%q kind=%q,期望 %q/%q(reason=%s)", tc.name, label, kind, tc.wantLabel, tc.wantKind, evidence.Reason)
}
if evidence.Reason != tc.wantReason {
t.Fatalf("%s:reason=%q,期望 %q", tc.name, evidence.Reason, tc.wantReason)
}
if tc.wantKind == RecordKindHistorical && state != VersionStateUnknown {
t.Fatalf("%s:历史提交必须是 unknown,得到 %q", tc.name, state)
}
}
}
func TestDecideVersionDevelopmentUsesBaseTagCount(t *testing.T) {
label, state, kind, evidence := DecideVersion("0.1.0", nil, "v0.1.0", intPtr(12))
if label != "0.1.12" || kind != RecordKindDevelopment || state != VersionStateVerified {
t.Fatalf("开发号计算错:%q/%q/%q", label, kind, state)
}
if evidence.BaseTag != "v0.1.0" || evidence.CommitsSinceBase == nil || *evidence.CommitsSinceBase != 12 {
t.Fatalf("依据不完整:%+v", evidence)
}
// 恰在发布提交上(0 个提交)⇒ 号就是该版本号
label, _, kind, _ = DecideVersion("0.1.0", nil, "v0.1.0", intPtr(0))
if label != "0.1.0" || kind != RecordKindDevelopment {
t.Fatalf("0 个提交时应等于版本号本身,得到 %q/%q", label, kind)
}
// 图不完整 ⇒ 不给号
label, _, kind, _ = DecideVersion("0.1.0", nil, "", nil)
if label != "" || kind != RecordKindHistorical {
t.Fatalf("无基线时必须不下发号,得到 %q/%q", label, kind)
}
}
func TestDecideVersionKeepsSameNumberForDifferentCommits(t *testing.T) {
// 两个提交算出同一个号:判定层不合并、不去重(唯一身份是 SHA)
first, _, _, _ := DecideVersion("0.1.0", nil, "v0.1.0", intPtr(3))
second, _, _, _ := DecideVersion("0.1.0", nil, "v0.1.0", intPtr(3))
if first != second || first != "0.1.3" {
t.Fatalf("同号应各自保留:%q / %q", first, second)
}
}
func TestChangelogTopAndReleaseNotes(t *testing.T) {
changelog := "# Changelog\n\n## [Unreleased]\n\n### Added\n\n- 新\n\n" +
"## [0.2.0] - 2026-10-13\n\n### Changed\n\n- **破坏性**:接口下线\n\n" +
"## [0.1.0] - 2026-10-01\n\n### Added\n\n- 旧\n"
if top := ChangelogTopVersion(changelog); top != "0.2.0" {
t.Fatalf("顶部版本 = %q,期望 0.2.0", top)
}
notes := ExtractReleaseNotes(changelog, "0.2.0")
if notes != "### Changed\n\n- **破坏性**:接口下线" {
t.Fatalf("0.2.0 段落 = %q", notes)
}
if ExtractReleaseNotes(changelog, "9.9.9") != "" {
t.Fatal("不存在的版本必须返回空,不许猜")
}
// 不带方括号的二级标题也是边界(与 check_changelog 同口径)
boundary := "# Changelog\n\n## [0.1.0] - 2026-10-01\n\n### Added\n\n- 甲\n\n## 能力基线(未发布)\n\n- 乙\n"
if notes := ExtractReleaseNotes(boundary, "0.1.0"); notes != "### Added\n\n- 甲" {
t.Fatalf("边界算错:%q", notes)
}
}
func TestSemverHelpers(t *testing.T) {
if !SemverValid("0.1.0") || SemverValid("01.1.0") || SemverValid("0.1") || SemverValid("v0.1.0") {
t.Fatal("三段无前导零校验不对")
}
if CompareSemver("0.10.0", "0.9.0") != 1 || CompareSemver("1.0.0", "1.0.0") != 0 || CompareSemver("0.1.0", "0.2.0") != -1 {
t.Fatal("版本比较不对")
}
}
+5
View File
@@ -139,6 +139,11 @@ func classifyRole(method, path string) string {
if strings.HasPrefix(path, "/api/v1/backup-schedule") && method != http.MethodGet {
return roleAdmin
}
// 版本记录的同步会拉起一次对远端仓库的抓取(消耗网络与数据库写入),
// 与"谁能改生产绑定"同级:读任何角色可看,同步只能管理员。
if strings.HasPrefix(path, "/api/v1/version-history") && method != http.MethodGet {
return roleAdmin
}
// Everything else that mutates (review, batch, seal, confirm, start, retry,
// route visibility, disable) is engineer work; viewers cannot POST at all.
return roleEngineer
+16
View File
@@ -111,6 +111,10 @@ type Config struct {
// enumerates the production-side bases the operator can bind.
// BackupSchedule 是「系统设置 → 存储与备份」的计划存储;BackupHost 负责在目标库
// 所在主机上读回/下发计划并触发一次整库备份(未接线时页面降级展示)。
// VersionHistory 是「版本记录」页的只读查询面;VersionHistoryRepo 是固定的仓库标识
// (owner/name)。两者都给才接线,否则页面降级为"未接线"(不报 500)。
VersionHistory VersionHistoryStore
VersionHistoryRepo string
BackupSchedule BackupScheduleStore
BackupHost BackupHost
BackupRuns *BackupRunTracker
@@ -318,6 +322,18 @@ func NewHandler(config Config) (http.Handler, error) {
mux.HandleFunc("GET /api/v1/monitor/overview", func(w http.ResponseWriter, r *http.Request) {
monitorOverview(w, r, config.Monitor)
})
// 「版本记录」:当前运行的构建身份(只读,与监控同级可见)
mux.HandleFunc("GET /api/v1/build-info", func(w http.ResponseWriter, r *http.Request) {
buildInfo(w, r)
})
// 全部 main 提交时间线(读本库缓存,不直接打 Gitea)
mux.HandleFunc("GET /api/v1/version-history", func(w http.ResponseWriter, r *http.Request) {
versionHistoryList(w, r, config)
})
// 手动同步一次(仅管理员;并发同步合并成同一个作业)
mux.HandleFunc("POST /api/v1/version-history/sync", func(w http.ResponseWriter, r *http.Request) {
versionHistorySync(w, r, config)
})
mux.HandleFunc("GET /api/v1/monitor/knowledge-bases/{id}/files", func(w http.ResponseWriter, r *http.Request) {
monitorFiles(w, r, config.Monitor)
})
+16 -10
View File
@@ -1489,16 +1489,19 @@ func TestHandlerRejectsCatalogMutationsWithoutOperationID(t *testing.T) {
}
type fakeJobStore struct {
createSettingsConnectionID string
createSettingsDraft postgresstore.SourceSettingsDraft
createSettingsOperationID string
createSettingsErr error
createSettingsReplayed bool
activateSettingsConnectionID string
activateSettingsRevisionID string
activateSettingsOperationID string
activateSettingsReplayed bool
activateSettingsErr error
createSettingsConnectionID string
createSettingsDraft postgresstore.SourceSettingsDraft
createSettingsOperationID string
createSettingsErr error
createSettingsReplayed bool
activateSettingsConnectionID string
activateSettingsRevisionID string
activateSettingsOperationID string
activateSettingsReplayed bool
activateSettingsErr error
// enqueueEchoRequestedID:幂等"首次入队"语义 —— 返回调用方请求的那个 id
// (默认 false 时返回 enqueueResult,即"复用已有任务"的路径)。
enqueueEchoRequestedID bool
listSettingsConnectionID string
listSettingsErr error
settingsRevisions []governance.SourceConnectionRevision
@@ -1867,6 +1870,9 @@ func (s *fakeJobStore) ActivateUISettingsWithOperation(_ context.Context, revisi
func (s *fakeJobStore) Enqueue(_ context.Context, request jobs.EnqueueRequest, _ time.Time) (*jobs.Job, error) {
s.enqueued = request
if s.enqueueEchoRequestedID {
return &jobs.Job{ID: request.ID, Type: request.Type, State: jobs.StatePending}, nil
}
return s.enqueueResult, nil
}
+257
View File
@@ -0,0 +1,257 @@
package httpapi
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"knowledge-governance/internal/governance"
"knowledge-governance/internal/jobs"
postgresstore "knowledge-governance/internal/platform/postgres"
"knowledge-governance/internal/version"
"knowledge-governance/internal/versionhistory"
)
// 「版本记录」页的服务面:**只读查询** + 管理员手动同步。
//
// 设计口径(见 docs/RELEASE_RUNBOOK.md 与 internal/governance/version_history.go):
// - 列表只读本库缓存(Gitea 不可达也能看),游标绑定快照 ⇒ 翻页不重不漏;
// - 同步只有一个入口,且只允许管理员触发;并发同步合并成同一个作业;
// - 「当前运行」只认构建产物注入的**完整 SHA 精确匹配**,拿不到就显示未知。
// 版本记录的作业类型(worker 侧注册同名 handler;值来自 versionhistory,避免两处写死)。
const VersionHistoryJobType = versionhistory.JobType
const (
versionHistoryDefaultPageSize = 30
versionHistoryMaxPageSize = 100
)
// VersionHistoryStore 是版本记录页需要的只读查询面(由 postgres.Store 实现)。
type VersionHistoryStore interface {
ActiveVersionHistorySnapshot(ctx context.Context, repoKey string) (int64, error)
LoadVersionHistoryPage(ctx context.Context, repoKey, kind, cursor string, limit int) (
[]governance.VersionHistoryCommit, string, bool, int64, error)
LoadVersionHistorySyncState(ctx context.Context, repoKey string) (governance.VersionHistorySyncRun, error)
VersionHistorySyncInFlight(ctx context.Context, repoKey string, now time.Time) (bool, error)
}
type versionHistoryTag struct {
Name string `json:"name"`
URL string `json:"url"`
}
type versionHistoryItem struct {
CommitSHA string `json:"commit_sha"`
ShortSHA string `json:"short_sha"`
Parents []string `json:"parents"`
CommittedAt string `json:"committed_at"`
AuthorDisplay string `json:"author_display"`
Subject string `json:"subject"`
Body string `json:"body"`
RecordKind string `json:"record_kind"`
VersionLabel string `json:"version_label"`
VersionState string `json:"version_state"`
ReleaseTags []versionHistoryTag `json:"release_tags"`
ReleaseNotes string `json:"release_notes"`
CommitURL string `json:"commit_url"`
VersionReason string `json:"version_reason"`
CurrentRunning bool `json:"is_current_running"`
}
type versionHistorySyncState struct {
Available bool `json:"available"`
Status string `json:"status"`
Complete bool `json:"complete"`
LastSuccessAt string `json:"last_success_at"`
FetchedCount int `json:"fetched_count"`
ExpectedCount *int `json:"expected_count"`
ErrorCode string `json:"error_code"`
SourceHeadSHA string `json:"source_head_sha"`
ActiveSnapshotID int64 `json:"active_snapshot_id"`
Repo string `json:"repo"`
}
type versionHistoryCurrentRunning struct {
State string `json:"state"`
CommitSHA string `json:"commit_sha"`
DisplayVersion string `json:"display_version"`
MatchedCommitSHA string `json:"matched_commit_sha"`
}
type versionHistoryResponse struct {
Items []versionHistoryItem `json:"items"`
NextCursor string `json:"next_cursor"`
HasMore bool `json:"has_more"`
Sync versionHistorySyncState `json:"sync"`
CurrentRunning versionHistoryCurrentRunning `json:"current_running"`
}
func versionHistoryError(w http.ResponseWriter, status int, code, message string) {
writeJSON(w, status, map[string]string{"error": message, "code": code})
}
// currentRunning 判定「当前运行」:只在完整 SHA 精确命中时标记,否则明确说未知。
func currentRunning(items []governance.VersionHistoryCommit) (versionHistoryCurrentRunning, string) {
info := version.Snapshot()
if !info.IdentityAvailable || info.CommitSHA == nil {
return versionHistoryCurrentRunning{State: "unknown",
DisplayVersion: info.DisplayVersion}, ""
}
sha := *info.CommitSHA
for _, item := range items {
if item.CommitSHA == sha {
return versionHistoryCurrentRunning{State: "matched", CommitSHA: sha,
DisplayVersion: info.DisplayVersion, MatchedCommitSHA: sha}, sha
}
}
state := "unknown"
if info.BuildKind == "development" {
state = "development"
}
// 开发构建:产物带完整 SHA,但它不在已同步的提交里 ⇒ 说明"当前运行"与页面记录不重合
return versionHistoryCurrentRunning{State: state, CommitSHA: sha,
DisplayVersion: info.DisplayVersion}, ""
}
func versionHistoryList(w http.ResponseWriter, r *http.Request, config Config) {
if config.VersionHistory == nil || config.VersionHistoryRepo == "" {
writeJSON(w, http.StatusOK, versionHistoryResponse{Items: []versionHistoryItem{},
Sync: versionHistorySyncState{Available: false},
CurrentRunning: func() versionHistoryCurrentRunning {
running, _ := currentRunning(nil)
return running
}()})
return
}
kind := strings.TrimSpace(r.URL.Query().Get("kind"))
if kind == "" {
kind = "all"
}
limit := versionHistoryDefaultPageSize
if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" {
parsed, err := strconv.Atoi(raw)
if err != nil || parsed <= 0 || parsed > versionHistoryMaxPageSize {
versionHistoryError(w, http.StatusBadRequest, "invalid_limit",
fmt.Sprintf("limit 必须是 1..%d 的整数", versionHistoryMaxPageSize))
return
}
limit = parsed
}
cursor := strings.TrimSpace(r.URL.Query().Get("cursor"))
items, nextCursor, hasMore, _, err := config.VersionHistory.LoadVersionHistoryPage(
r.Context(), config.VersionHistoryRepo, kind, cursor, limit)
if err != nil {
if errors.Is(err, postgresstore.ErrVersionHistoryCursorExpired) {
versionHistoryError(w, http.StatusConflict, "cursor_expired",
"这次翻页依据的记录快照已被清理,请刷新页面重新加载")
return
}
versionHistoryError(w, http.StatusInternalServerError, "version_history_unavailable", "读取版本记录失败")
return
}
if items == nil {
items = []governance.VersionHistoryCommit{}
}
state, err := config.VersionHistory.LoadVersionHistorySyncState(r.Context(), config.VersionHistoryRepo)
if err != nil {
versionHistoryError(w, http.StatusInternalServerError, "version_history_unavailable", "读取同步状态失败")
return
}
running, matchedSHA := currentRunning(items)
payload := versionHistoryResponse{
Items: make([]versionHistoryItem, 0, len(items)),
NextCursor: nextCursor, HasMore: hasMore, CurrentRunning: running,
Sync: versionHistorySyncState{Available: true, Status: state.Status, Complete: state.Complete,
LastSuccessAt: versionHistoryTime(state.LastSuccessAt), FetchedCount: state.FetchedCount,
ExpectedCount: state.ExpectedCount, ErrorCode: state.ErrorCode,
SourceHeadSHA: state.SourceHeadSHA, ActiveSnapshotID: state.ActiveSnapshotID,
Repo: config.VersionHistoryRepo},
}
for _, item := range items {
record := versionHistoryItem{
CommitSHA: item.CommitSHA, ShortSHA: shortSHA(item.CommitSHA), Parents: item.Parents,
CommittedAt: versionHistoryTime(item.CommittedAt), AuthorDisplay: item.AuthorDisplay,
Subject: item.Subject, Body: item.Body, RecordKind: item.RecordKind,
VersionLabel: item.VersionLabel, VersionState: item.VersionState,
ReleaseNotes: item.ReleaseNotes, CommitURL: item.CommitURL,
VersionReason: item.VersionEvidence.Reason,
ReleaseTags: make([]versionHistoryTag, 0, len(item.ReleaseTags)),
CurrentRunning: matchedSHA != "" && item.CommitSHA == matchedSHA,
}
for _, tag := range item.ReleaseTags {
record.ReleaseTags = append(record.ReleaseTags, versionHistoryTag{Name: tag.Name, URL: tag.URL})
}
payload.Items = append(payload.Items, record)
}
writeJSON(w, http.StatusOK, payload)
}
func versionHistorySync(w http.ResponseWriter, r *http.Request, config Config) {
if config.VersionHistory == nil || config.VersionHistoryRepo == "" {
versionHistoryError(w, http.StatusServiceUnavailable, "version_history_unavailable",
"版本记录未接线:没有配置只读的仓库来源")
return
}
now := config.Now().UTC()
inFlight, err := config.VersionHistory.VersionHistorySyncInFlight(r.Context(), config.VersionHistoryRepo, now)
if err != nil {
versionHistoryError(w, http.StatusInternalServerError, "version_history_unavailable", "查询同步状态失败")
return
}
if inFlight {
state, err := config.VersionHistory.LoadVersionHistorySyncState(r.Context(), config.VersionHistoryRepo)
if err != nil {
versionHistoryError(w, http.StatusInternalServerError, "version_history_unavailable", "查询同步状态失败")
return
}
// 已经在跑:不重复入队,明确告诉调用方复用现有任务
writeJSON(w, http.StatusOK, map[string]any{"job_id": state.JobID, "sync_run_id": state.ID,
"status": state.Status, "reused": true})
return
}
triggeredBy := ""
if identity, ok := sessionFrom(r.Context()); ok {
triggeredBy = identity.Username
}
payload, err := json.Marshal(map[string]string{"repo_key": config.VersionHistoryRepo, "triggered_by": triggeredBy})
if err != nil {
versionHistoryError(w, http.StatusInternalServerError, "version_history_unavailable", "构造同步任务失败")
return
}
requestedID := fmt.Sprintf("version-history-sync-%d", now.UnixNano())
job, err := config.Store.Enqueue(r.Context(), jobs.EnqueueRequest{ID: requestedID,
Type: VersionHistoryJobType, Payload: payload, AvailableAt: now, MaxAttempts: 3,
IdempotencyKey: "version-history.sync:" + config.VersionHistoryRepo}, now)
if err != nil || job == nil {
versionHistoryError(w, http.StatusInternalServerError, "version_history_unavailable", "入队同步任务失败")
return
}
writeJSON(w, http.StatusAccepted, map[string]any{"job_id": job.ID, "status": job.State,
"reused": job.ID != requestedID})
}
// buildInfo:当前运行产物的构建身份(只读;与监控接口同级可见)。
func buildInfo(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, version.Snapshot())
}
func versionHistoryTime(value time.Time) string {
if value.IsZero() {
return ""
}
return value.UTC().Format(time.RFC3339)
}
func shortSHA(sha string) string {
if len(sha) > 12 {
return sha[:12]
}
return sha
}
+286
View File
@@ -0,0 +1,286 @@
package httpapi
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"knowledge-governance/internal/governance"
"knowledge-governance/internal/jobs"
postgresstore "knowledge-governance/internal/platform/postgres"
"knowledge-governance/internal/version"
)
type fakeVersionHistoryStore struct {
items []governance.VersionHistoryCommit
nextCursor string
hasMore bool
snapshot int64
pageErr error
state governance.VersionHistorySyncRun
stateErr error
inFlight bool
inFlightErr error
lastKind string
lastCursor string
lastLimit int
}
func (f *fakeVersionHistoryStore) ActiveVersionHistorySnapshot(context.Context, string) (int64, error) {
return f.snapshot, nil
}
func (f *fakeVersionHistoryStore) LoadVersionHistoryPage(_ context.Context, _, kind, cursor string, limit int) (
[]governance.VersionHistoryCommit, string, bool, int64, error) {
f.lastKind, f.lastCursor, f.lastLimit = kind, cursor, limit
if f.pageErr != nil {
return nil, "", false, f.snapshot, f.pageErr
}
return f.items, f.nextCursor, f.hasMore, f.snapshot, nil
}
func (f *fakeVersionHistoryStore) LoadVersionHistorySyncState(context.Context, string) (governance.VersionHistorySyncRun, error) {
return f.state, f.stateErr
}
func (f *fakeVersionHistoryStore) VersionHistorySyncInFlight(context.Context, string, time.Time) (bool, error) {
return f.inFlight, f.inFlightErr
}
func decodeVersionHistoryBody(t *testing.T, recorder *httptest.ResponseRecorder) map[string]any {
t.Helper()
var payload map[string]any
if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil {
t.Fatalf("响应不是 JSON:%v(%s)", err, recorder.Body.String())
}
return payload
}
// 注入的完整 SHA 决定「当前运行」;不注入就只能是未知 —— 绝不拿最新版本顶替。
func withInjectedIdentity(t *testing.T, sha, kind string) {
t.Helper()
previousSHA, previousKind, previousRelease := version.CommitSHA, version.BuildKind, version.ReleaseVersion
version.CommitSHA, version.BuildKind, version.ReleaseVersion = sha, kind, "0.1.0"
t.Cleanup(func() {
version.CommitSHA, version.BuildKind, version.ReleaseVersion = previousSHA, previousKind, previousRelease
})
}
func TestBuildInfoReportsIdentityAvailability(t *testing.T) {
sha := strings.Repeat("a", 40)
withInjectedIdentity(t, sha, "development")
recorder := httptest.NewRecorder()
buildInfo(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/build-info", nil))
if recorder.Code != http.StatusOK {
t.Fatalf("状态码 = %d", recorder.Code)
}
payload := decodeVersionHistoryBody(t, recorder)
if payload["identity_available"] != true || payload["commit_sha"] != sha {
t.Fatalf("完整注入时应给出身份:%v", payload)
}
if payload["build_kind"] != "development" {
t.Fatalf("构建类型 = %v", payload["build_kind"])
}
withInjectedIdentity(t, "unknown", "release")
recorder = httptest.NewRecorder()
buildInfo(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/build-info", nil))
payload = decodeVersionHistoryBody(t, recorder)
if payload["identity_available"] != false || payload["commit_sha"] != nil || payload["display_version"] != "dev" {
t.Fatalf("身份不可用时必须明确说未知:%v", payload)
}
}
func TestVersionHistoryListUnconfiguredStaysReadable(t *testing.T) {
withInjectedIdentity(t, "unknown", "development")
recorder := httptest.NewRecorder()
versionHistoryList(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/version-history", nil), Config{})
if recorder.Code != http.StatusOK {
t.Fatalf("未接线也必须 200(页面降级展示):%d", recorder.Code)
}
payload := decodeVersionHistoryBody(t, recorder)
sync, _ := payload["sync"].(map[string]any)
if sync["available"] != false {
t.Fatalf("未接线时 sync.available 必须为 false:%v", sync)
}
items, _ := payload["items"].([]any)
if len(items) != 0 {
t.Fatalf("未接线时不该有记录:%v", items)
}
running, _ := payload["current_running"].(map[string]any)
if running["state"] != "unknown" {
t.Fatalf("拿不到身份必须显示未知:%v", running)
}
}
func TestVersionHistoryListMarksOnlyExactMatchAndKeepsSameNumberApart(t *testing.T) {
sha := strings.Repeat("b", 40)
other := strings.Repeat("c", 40)
withInjectedIdentity(t, sha, "development")
store := &fakeVersionHistoryStore{snapshot: 7, hasMore: true, nextCursor: "cursor-1",
items: []governance.VersionHistoryCommit{
{CommitSHA: sha, CommittedAt: time.Date(2026, 10, 13, 10, 0, 0, 0, time.UTC),
Subject: "feat: 当前运行的这条", RecordKind: governance.RecordKindDevelopment,
VersionLabel: "0.1.3", VersionState: governance.VersionStateVerified,
VersionEvidence: governance.VersionEvidence{Reason: "counted_commits_since_base_tag"}},
{CommitSHA: other, CommittedAt: time.Date(2026, 10, 13, 9, 0, 0, 0, time.UTC),
Subject: "feat: 同号的另一条", RecordKind: governance.RecordKindDevelopment,
VersionLabel: "0.1.3", VersionState: governance.VersionStateVerified},
}}
config := Config{VersionHistory: store, VersionHistoryRepo: "WeknorA/knowledge-governance"}
recorder := httptest.NewRecorder()
versionHistoryList(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/version-history?kind=development&limit=2", nil), config)
if recorder.Code != http.StatusOK {
t.Fatalf("状态码 = %d", recorder.Code)
}
if store.lastKind != "development" || store.lastLimit != 2 {
t.Fatalf("查询参数没透传:kind=%s limit=%d", store.lastKind, store.lastLimit)
}
payload := decodeVersionHistoryBody(t, recorder)
items, _ := payload["items"].([]any)
if len(items) != 2 {
t.Fatalf("记录数 = %d", len(items))
}
flagged := 0
for _, raw := range items {
item, _ := raw.(map[string]any)
if item["is_current_running"] == true {
flagged++
if item["commit_sha"] != sha {
t.Fatalf("只允许精确匹配的那条被标记:%v", item)
}
}
// 同号不同 SHA:两条都在,且都带自己的短 SHA
if item["version_label"] != "0.1.3" || item["short_sha"] == "" {
t.Fatalf("同号的记录应各自保留并以短 SHA 区分:%v", item)
}
}
if flagged != 1 {
t.Fatalf("被标记为当前运行的条数 = %d", flagged)
}
if payload["has_more"] != true || payload["next_cursor"] != "cursor-1" {
t.Fatalf("分页游标没透传:%v", payload)
}
running, _ := payload["current_running"].(map[string]any)
if running["state"] != "matched" || running["matched_commit_sha"] != sha {
t.Fatalf("当前运行判定不对:%v", running)
}
}
func TestVersionHistoryListDevelopmentBuildNotInRecordsIsNotMarked(t *testing.T) {
withInjectedIdentity(t, strings.Repeat("d", 40), "development")
store := &fakeVersionHistoryStore{snapshot: 3, items: []governance.VersionHistoryCommit{
{CommitSHA: strings.Repeat("e", 40), VersionLabel: "0.1.5", RecordKind: governance.RecordKindDevelopment}}}
config := Config{VersionHistory: store, VersionHistoryRepo: "o/r"}
recorder := httptest.NewRecorder()
versionHistoryList(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/version-history", nil), config)
payload := decodeVersionHistoryBody(t, recorder)
items, _ := payload["items"].([]any)
for _, raw := range items {
item, _ := raw.(map[string]any)
if item["is_current_running"] == true {
t.Fatalf("产物 SHA 不在记录里就不得标记任何条目:%v", item)
}
}
running, _ := payload["current_running"].(map[string]any)
if running["state"] != "development" || running["commit_sha"] == "" {
t.Fatalf("开发构建要显式标为开发构建,而不是标成某条记录:%v", running)
}
}
func TestVersionHistoryListRejectsBadLimitAndExpiredCursor(t *testing.T) {
withInjectedIdentity(t, "unknown", "development")
store := &fakeVersionHistoryStore{snapshot: 5}
config := Config{VersionHistory: store, VersionHistoryRepo: "o/r"}
recorder := httptest.NewRecorder()
versionHistoryList(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/version-history?limit=999", nil), config)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("limit 越界应 400,得到 %d", recorder.Code)
}
if code := decodeVersionHistoryBody(t, recorder)["code"]; code != "invalid_limit" {
t.Fatalf("错误码 = %v", code)
}
store.pageErr = postgresstore.ErrVersionHistoryCursorExpired
recorder = httptest.NewRecorder()
versionHistoryList(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/version-history?cursor=stale", nil), config)
if recorder.Code != http.StatusConflict {
t.Fatalf("游标过期应 409,得到 %d", recorder.Code)
}
if code := decodeVersionHistoryBody(t, recorder)["code"]; code != "cursor_expired" {
t.Fatalf("错误码 = %v", code)
}
}
func TestVersionHistorySyncRequiresAdminAndEnqueuesOnce(t *testing.T) {
// 角色:读任何角色可看,同步只能管理员
if got := classifyRole(http.MethodGet, "/api/v1/version-history"); got != roleViewer {
t.Fatalf("GET 的最小角色 = %q", got)
}
if got := classifyRole(http.MethodPost, "/api/v1/version-history/sync"); got != roleAdmin {
t.Fatalf("同步的最小角色 = %q,必须是 %q", got, roleAdmin)
}
now := time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)
queue := &fakeJobStore{enqueueEchoRequestedID: true}
store := &fakeVersionHistoryStore{snapshot: 9}
config := Config{Store: queue, VersionHistory: store, VersionHistoryRepo: "Weknora/knowledge-governance",
Now: func() time.Time { return now }}
recorder := httptest.NewRecorder()
versionHistorySync(recorder, httptest.NewRequest(http.MethodPost, "/api/v1/version-history/sync", nil), config)
if recorder.Code != http.StatusAccepted {
t.Fatalf("首次同步应 202,得到 %d(%s)", recorder.Code, recorder.Body.String())
}
if queue.enqueued.Type != VersionHistoryJobType || queue.enqueued.IdempotencyKey != "version-history.sync:Weknora/knowledge-governance" {
t.Fatalf("入队参数不对:%+v", queue.enqueued)
}
if queue.enqueued.MaxAttempts != 3 || queue.enqueued.AvailableAt != now {
t.Fatalf("入队参数不对:%+v", queue.enqueued)
}
payload := decodeVersionHistoryBody(t, recorder)
if payload["reused"] != false {
t.Fatalf("首次不该标成复用:%v", payload)
}
// 已经在跑:不重复入队,明确返回已有任务
store.inFlight = true
store.state = governance.VersionHistorySyncRun{ID: 11, JobID: "job-running", Status: "running"}
queue.enqueued = jobs.EnqueueRequest{}
recorder = httptest.NewRecorder()
versionHistorySync(recorder, httptest.NewRequest(http.MethodPost, "/api/v1/version-history/sync", nil), config)
if recorder.Code != http.StatusOK {
t.Fatalf("并发同步应 200 + 复用已有任务,得到 %d", recorder.Code)
}
payload = decodeVersionHistoryBody(t, recorder)
if payload["reused"] != true || payload["job_id"] != "job-running" {
t.Fatalf("应复用已有任务:%v", payload)
}
if queue.enqueued.Type != "" {
t.Fatalf("已在跑时不得再次入队:%+v", queue.enqueued)
}
}
func TestVersionHistorySyncUnavailableWhenNotWired(t *testing.T) {
recorder := httptest.NewRecorder()
versionHistorySync(recorder, httptest.NewRequest(http.MethodPost, "/api/v1/version-history/sync", nil), Config{})
if recorder.Code != http.StatusServiceUnavailable {
t.Fatalf("未接线应 503,得到 %d", recorder.Code)
}
if code := decodeVersionHistoryBody(t, recorder)["code"]; code != "version_history_unavailable" {
t.Fatalf("错误码 = %v", code)
}
// 未接线时页面仍然可读(只是没有记录)
store := &fakeVersionHistoryStore{stateErr: errors.New("boom")}
recorder = httptest.NewRecorder()
config := Config{VersionHistory: store, VersionHistoryRepo: "o/r"}
versionHistoryList(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/version-history", nil), config)
if recorder.Code != http.StatusInternalServerError {
t.Fatalf("查询异常应 500,得到 %d", recorder.Code)
}
}
@@ -0,0 +1,4 @@
DROP INDEX IF EXISTS version_history_sync_runs_recent_idx;
DROP INDEX IF EXISTS version_history_commits_page_idx;
DROP TABLE IF EXISTS version_history_sync_runs;
DROP TABLE IF EXISTS version_history_commits;
@@ -0,0 +1,56 @@
-- 「版本记录」页的数据:**远端 main 可达的每一个提交**(含合并提交与已并入 main 的侧分支提交),
-- 以及每次同步的状态。
--
-- 口径(见 docs/RELEASE_RUNBOOK.md 与 internal/governance/version_history.go):
-- * commit_sha(完整 40 位)是**唯一身份**;version_label **不是唯一键** —— 两个提交算出同一个
-- 开发号时各占一行,用 SHA 区分。
-- * 证据不足一律 version_state='unknown' + record_kind='historical',**不补造版本号**。
-- * 快照 = 一次同步(snapshot_id = version_history_sync_runs.id);对外只服务**最近一次成功**的快照
-- ⇒ 抓取过程中旧数据继续可读,新快照抓完并计数对账通过后才成为事实。
CREATE TABLE version_history_commits (
repo_key TEXT NOT NULL,
commit_sha CHAR(40) NOT NULL,
parents TEXT[] NOT NULL DEFAULT '{}',
committed_at TIMESTAMPTZ NOT NULL,
author_display TEXT NOT NULL DEFAULT '',
subject TEXT NOT NULL DEFAULT '',
body TEXT NOT NULL DEFAULT '',
version_label TEXT,
version_state TEXT NOT NULL,
record_kind TEXT NOT NULL,
release_tags JSONB NOT NULL DEFAULT '[]'::JSONB,
release_notes TEXT NOT NULL DEFAULT '',
commit_url TEXT NOT NULL DEFAULT '',
version_evidence JSONB NOT NULL DEFAULT '{}'::JSONB,
snapshot_id BIGINT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (repo_key, commit_sha),
CONSTRAINT version_history_commits_kind CHECK (record_kind IN ('release', 'development', 'historical')),
CONSTRAINT version_history_commits_state CHECK (version_state IN ('verified', 'unknown')),
CONSTRAINT version_history_commits_sha CHECK (commit_sha ~ '^[0-9a-f]{40}$')
);
-- 分页排序 (committed_at DESC, commit_sha DESC) 的覆盖索引:游标翻页不重不漏。
CREATE INDEX version_history_commits_page_idx
ON version_history_commits (repo_key, snapshot_id, committed_at DESC, commit_sha DESC);
CREATE TABLE version_history_sync_runs (
id BIGSERIAL PRIMARY KEY,
repo_key TEXT NOT NULL,
job_id TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL,
source_head_sha TEXT NOT NULL DEFAULT '',
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
finished_at TIMESTAMPTZ,
last_success_at TIMESTAMPTZ,
complete BOOLEAN NOT NULL DEFAULT FALSE,
fetched_count INTEGER NOT NULL DEFAULT 0,
expected_count INTEGER,
error_code TEXT NOT NULL DEFAULT '',
triggered_by TEXT NOT NULL DEFAULT '',
CONSTRAINT version_history_sync_runs_status
CHECK (status IN ('pending', 'running', 'completed', 'failed', 'cancelled'))
);
CREATE INDEX version_history_sync_runs_recent_idx
ON version_history_sync_runs (repo_key, id DESC);
+12 -2
View File
@@ -11,8 +11,8 @@ func TestEmbeddedMigrationsAreVersionSorted(t *testing.T) {
if err != nil {
t.Fatalf("loadMigrations() error = %v", err)
}
if len(migrations) != 45 {
t.Fatalf("migration count = %d, want 45", len(migrations))
if len(migrations) != 46 {
t.Fatalf("migration count = %d, want 46", len(migrations))
}
if migrations[0].Version != 1 || !strings.Contains(migrations[0].UpSQL, "CREATE TABLE IF NOT EXISTS jobs") {
t.Fatalf("migration 1 = %#v", migrations[0])
@@ -115,6 +115,16 @@ func TestEmbeddedMigrationsAreVersionSorted(t *testing.T) {
if migrations[44].Version != 45 || !strings.Contains(migrations[44].UpSQL, "CREATE TABLE backup_schedule") {
t.Fatalf("migration 45 = %#v", migrations[44])
}
// 000046:「版本记录」的提交记录与同步状态;唯一身份是完整 commit_sha,
// 版本号不是唯一键;证据不足只落 historical(不许编版本号)。
if migrations[45].Version != 46 || !strings.Contains(migrations[45].UpSQL, "CREATE TABLE version_history_commits") ||
!strings.Contains(migrations[45].UpSQL, "CREATE TABLE version_history_sync_runs") ||
!strings.Contains(migrations[45].UpSQL, "'release', 'development', 'historical'") {
t.Fatalf("migration 46 = %#v", migrations[45])
}
if !strings.Contains(migrations[45].DownSQL, "DROP TABLE IF EXISTS version_history_commits") {
t.Fatalf("migration 46 down = %#v", migrations[45].DownSQL)
}
}
func TestProcessingConfirmationMigrationSeparatesPlanningFromUpload(t *testing.T) {
@@ -0,0 +1,315 @@
package postgres
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
"knowledge-governance/internal/governance"
"knowledge-governance/internal/versionhistory"
)
// 「版本记录」的持久化:提交记录 + 同步状态(migration 000046)。
//
// 事实口径(与 internal/governance、internal/versionhistory 一致):
// - commit_sha 是唯一身份;同一提交再次同步只更新自己那一行(后补 tag 靠这条生效);
// - 对外只服务**最近一次成功**的快照(snapshot_id = sync_runs.id);
// - 失败不切换快照、不清空历史。
const (
// VersionHistoryKeptSnapshots:保留最近几个成功快照供游标使用,更旧的清理。
VersionHistoryKeptSnapshots = 3
// VersionHistoryInFlightWindow:超过这个时长的 running 视为"陈旧"(进程被杀留下的),
// 不再挡住新的同步请求。
VersionHistoryInFlightWindow = 30 * time.Minute
versionHistoryDefaultLimit = 30
versionHistoryMaxLimit = 100
)
// ErrVersionHistoryCursorExpired:游标指向的快照已被清理 ⇒ 调用方返回 409 让页面刷新。
var ErrVersionHistoryCursorExpired = errors.New("version history cursor expired")
// BeginVersionHistorySync 开一次同步(status=running),返回 run id(同时就是快照 id)。
func (s *Store) BeginVersionHistorySync(ctx context.Context, repoKey, jobID, triggeredBy string, now time.Time) (int64, error) {
var id int64
err := s.db.QueryRow(ctx, `
INSERT INTO version_history_sync_runs (repo_key, job_id, status, started_at, triggered_by)
VALUES ($1, $2, 'running', $3, $4)
RETURNING id`, repoKey, jobID, now.UTC(), triggeredBy).Scan(&id)
if err != nil {
return 0, fmt.Errorf("开启版本记录同步:%w", err)
}
return id, nil
}
// VersionHistorySyncInFlight 是否有在跑的同步(陈旧 running 不算)。
func (s *Store) VersionHistorySyncInFlight(ctx context.Context, repoKey string, now time.Time) (bool, error) {
var exists bool
err := s.db.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1 FROM version_history_sync_runs
WHERE repo_key = $1 AND status IN ('pending', 'running') AND started_at > $2)`,
repoKey, now.UTC().Add(-VersionHistoryInFlightWindow)).Scan(&exists)
if err != nil {
return false, fmt.Errorf("查询版本记录同步状态:%w", err)
}
return exists, nil
}
// SaveVersionHistorySnapshot 写入本次快照的全部提交记录(同一提交只更新自己那一行),
// 并清理过旧的快照(**不动本次快照**)。
func (s *Store) SaveVersionHistorySnapshot(ctx context.Context, snapshotID int64, repoKey string,
records []governance.VersionHistoryCommit, expectedCount int, complete bool, now time.Time) error {
// expectedCount/complete 由 FinishVersionHistorySync 落库(这里只写记录行)。
_, _ = expectedCount, complete
tx, err := s.db.Begin(ctx)
if err != nil {
return fmt.Errorf("写版本记录快照:%w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
for _, record := range records {
parents := record.Parents
if parents == nil {
parents = []string{}
}
tags, err := json.Marshal(record.ReleaseTags)
if err != nil {
return fmt.Errorf("序列化 tag 证据:%w", err)
}
evidence, err := json.Marshal(record.VersionEvidence)
if err != nil {
return fmt.Errorf("序列化版本依据:%w", err)
}
var label *string
if record.VersionLabel != "" {
value := record.VersionLabel
label = &value
}
if _, err := tx.Exec(ctx, `
INSERT INTO version_history_commits (repo_key, commit_sha, parents, committed_at, author_display,
subject, body, version_label, version_state, record_kind, release_tags, release_notes,
commit_url, version_evidence, snapshot_id, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)
ON CONFLICT (repo_key, commit_sha) DO UPDATE SET
parents = EXCLUDED.parents, committed_at = EXCLUDED.committed_at,
author_display = EXCLUDED.author_display, subject = EXCLUDED.subject, body = EXCLUDED.body,
version_label = EXCLUDED.version_label, version_state = EXCLUDED.version_state,
record_kind = EXCLUDED.record_kind, release_tags = EXCLUDED.release_tags,
release_notes = EXCLUDED.release_notes, commit_url = EXCLUDED.commit_url,
version_evidence = EXCLUDED.version_evidence, snapshot_id = EXCLUDED.snapshot_id,
updated_at = EXCLUDED.updated_at`,
repoKey, record.CommitSHA, parents, record.CommittedAt.UTC(), record.AuthorDisplay,
record.Subject, record.Body, label, record.VersionState, record.RecordKind,
tags, record.ReleaseNotes, record.CommitURL, evidence, snapshotID, now.UTC()); err != nil {
return fmt.Errorf("写入版本记录 %s:%w", record.CommitSHA, err)
}
}
if _, err := tx.Exec(ctx, `
DELETE FROM version_history_commits
WHERE repo_key = $1 AND snapshot_id <> $2 AND snapshot_id NOT IN (
SELECT id FROM version_history_sync_runs
WHERE repo_key = $1 AND status = 'completed' ORDER BY id DESC LIMIT $3)`,
repoKey, snapshotID, VersionHistoryKeptSnapshots); err != nil {
return fmt.Errorf("清理过旧版本记录快照:%w", err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("提交版本记录快照:%w", err)
}
return nil
}
// FinishVersionHistorySync 标记同步成功(此时本次快照成为对外服务的事实)。
func (s *Store) FinishVersionHistorySync(ctx context.Context, runID int64, outcome versionhistory.SyncOutcome, now time.Time) error {
command, err := s.db.Exec(ctx, `
UPDATE version_history_sync_runs
SET status = 'completed', finished_at = $2, last_success_at = $2, complete = $3,
fetched_count = $4, expected_count = $5, source_head_sha = $6
WHERE id = $1`,
runID, now.UTC(), outcome.Complete, outcome.FetchedCount, outcome.ExpectedCount, outcome.SourceHeadSHA)
if err != nil {
return fmt.Errorf("完成版本记录同步:%w", err)
}
if command.RowsAffected() != 1 {
return fmt.Errorf("完成版本记录同步:run %d 不存在", runID)
}
return nil
}
// FailVersionHistorySync 标记同步失败并落错误码(**不切换对外快照**)。
func (s *Store) FailVersionHistorySync(ctx context.Context, runID int64, errorCode string, now time.Time) error {
_, err := s.db.Exec(ctx, `
UPDATE version_history_sync_runs
SET status = 'failed', finished_at = $2, error_code = $3
WHERE id = $1`, runID, now.UTC(), errorCode)
if err != nil {
return fmt.Errorf("标记版本记录同步失败:%w", err)
}
return nil
}
// ActiveVersionHistorySnapshot 返回对外快照(最近一次成功同步);没有 ⇒ 0。
func (s *Store) ActiveVersionHistorySnapshot(ctx context.Context, repoKey string) (int64, error) {
var id int64
err := s.db.QueryRow(ctx, `
SELECT id FROM version_history_sync_runs
WHERE repo_key = $1 AND status = 'completed' ORDER BY id DESC LIMIT 1`, repoKey).Scan(&id)
if errors.Is(err, pgx.ErrNoRows) {
return 0, nil
}
if err != nil {
return 0, fmt.Errorf("查询版本记录快照:%w", err)
}
return id, nil
}
// LoadVersionHistorySyncState 读同步状态:最新一次 run(任何终态)+ 最近一次成功时间。
func (s *Store) LoadVersionHistorySyncState(ctx context.Context, repoKey string) (governance.VersionHistorySyncRun, error) {
var run governance.VersionHistorySyncRun
var expected *int
err := s.db.QueryRow(ctx, `
SELECT id, job_id, status, source_head_sha, started_at, finished_at, complete,
fetched_count, expected_count, error_code, triggered_by
FROM version_history_sync_runs WHERE repo_key = $1 ORDER BY id DESC LIMIT 1`, repoKey).
Scan(&run.ID, &run.JobID, &run.Status, &run.SourceHeadSHA, &run.StartedAt, &run.FinishedAt,
&run.Complete, &run.FetchedCount, &expected, &run.ErrorCode, &run.TriggeredBy)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return governance.VersionHistorySyncRun{}, fmt.Errorf("查询同步状态:%w", err)
}
if errors.Is(err, pgx.ErrNoRows) {
return governance.VersionHistorySyncRun{}, nil
}
run.ExpectedCount = expected
if snapshot, err := s.ActiveVersionHistorySnapshot(ctx, repoKey); err != nil {
return governance.VersionHistorySyncRun{}, err
} else {
run.ActiveSnapshotID = snapshot
}
var lastSuccess *time.Time
if err := s.db.QueryRow(ctx, `
SELECT last_success_at FROM version_history_sync_runs
WHERE repo_key = $1 AND status = 'completed' ORDER BY id DESC LIMIT 1`, repoKey).
Scan(&lastSuccess); err == nil && lastSuccess != nil {
run.LastSuccessAt = *lastSuccess
}
return run, nil
}
// VersionHistoryCursorBetween 偏移一页:绑定快照 + (committed_at, commit_sha) 组合游标。
func encodeVersionHistoryCursor(snapshotID int64, committedAt time.Time, sha string) string {
raw := fmt.Sprintf("%d|%s|%s", snapshotID, committedAt.UTC().Format(time.RFC3339Nano), sha)
return base64.RawURLEncoding.EncodeToString([]byte(raw))
}
func decodeVersionHistoryCursor(cursor string) (int64, time.Time, string, error) {
decoded, err := base64.RawURLEncoding.DecodeString(cursor)
if err != nil {
return 0, time.Time{}, "", fmt.Errorf("游标不是合法编码")
}
parts := strings.Split(string(decoded), "|")
if len(parts) != 3 {
return 0, time.Time{}, "", fmt.Errorf("游标结构不对")
}
var snapshotID int64
if _, err := fmt.Sscanf(parts[0], "%d", &snapshotID); err != nil || snapshotID <= 0 {
return 0, time.Time{}, "", fmt.Errorf("游标缺少快照")
}
committedAt, err := time.Parse(time.RFC3339Nano, parts[1])
if err != nil {
return 0, time.Time{}, "", fmt.Errorf("游标的提交时间不对")
}
if len(parts[2]) != 40 {
return 0, time.Time{}, "", fmt.Errorf("游标的提交标识不对")
}
return snapshotID, committedAt.UTC(), parts[2], nil
}
// LoadVersionHistoryPage 按游标读一页(只读当前快照;跨快照游标直接拒绝)。
func (s *Store) LoadVersionHistoryPage(ctx context.Context, repoKey, kind, cursor string, limit int) (
items []governance.VersionHistoryCommit, nextCursor string, hasMore bool, snapshotID int64, err error) {
if limit <= 0 {
limit = versionHistoryDefaultLimit
}
if limit > versionHistoryMaxLimit {
limit = versionHistoryMaxLimit
}
switch kind {
case "", "all", governance.RecordKindRelease, governance.RecordKindDevelopment, governance.RecordKindHistorical:
default:
return nil, "", false, 0, fmt.Errorf("未知的记录类型 %q", kind)
}
if kind == "" {
kind = "all"
}
active, err := s.ActiveVersionHistorySnapshot(ctx, repoKey)
if err != nil {
return nil, "", false, 0, err
}
var (
cursorAt *time.Time
cursorSHA *string
)
if cursor != "" {
cursorSnapshot, committedAt, sha, decodeErr := decodeVersionHistoryCursor(cursor)
if decodeErr != nil {
return nil, "", false, 0, fmt.Errorf("%w:%s", ErrVersionHistoryCursorExpired, decodeErr)
}
if cursorSnapshot != active {
return nil, "", false, active, ErrVersionHistoryCursorExpired
}
cursorAt, cursorSHA = &committedAt, &sha
}
if active == 0 {
return []governance.VersionHistoryCommit{}, "", false, 0, nil
}
rows, err := s.db.Query(ctx, `
SELECT commit_sha, parents, committed_at, author_display, subject, body, version_label,
version_state, record_kind, release_tags, release_notes, commit_url, version_evidence, snapshot_id
FROM version_history_commits
WHERE repo_key = $1 AND snapshot_id = $2 AND ($3 = 'all' OR record_kind = $3)
AND ($4::TIMESTAMPTZ IS NULL OR (committed_at, commit_sha) < ($4, $5))
ORDER BY committed_at DESC, commit_sha DESC
LIMIT $6`, repoKey, active, kind, cursorAt, cursorSHA, limit+1)
if err != nil {
return nil, "", false, active, fmt.Errorf("读取版本记录:%w", err)
}
defer rows.Close()
for rows.Next() {
var (
record governance.VersionHistoryCommit
label *string
tagsRaw []byte
evidRaw []byte
)
if err := rows.Scan(&record.CommitSHA, &record.Parents, &record.CommittedAt, &record.AuthorDisplay,
&record.Subject, &record.Body, &label, &record.VersionState, &record.RecordKind,
&tagsRaw, &record.ReleaseNotes, &record.CommitURL, &evidRaw, &record.SnapshotID); err != nil {
return nil, "", false, active, fmt.Errorf("解析版本记录:%w", err)
}
record.RepoKey = repoKey
if label != nil {
record.VersionLabel = *label
}
if len(tagsRaw) > 0 {
_ = json.Unmarshal(tagsRaw, &record.ReleaseTags)
}
if len(evidRaw) > 0 {
_ = json.Unmarshal(evidRaw, &record.VersionEvidence)
}
items = append(items, record)
}
if err := rows.Err(); err != nil {
return nil, "", false, active, fmt.Errorf("读取版本记录:%w", err)
}
if len(items) > limit {
items = items[:limit]
hasMore = true
last := items[len(items)-1]
nextCursor = encodeVersionHistoryCursor(active, last.CommittedAt, last.CommitSHA)
}
return items, nextCursor, hasMore, active, nil
}
+35
View File
@@ -0,0 +1,35 @@
package version
// 构建身份(「版本记录」页的"当前运行"判据)。**只回身份字段**:不含主机、配置或任何凭据。
//
// 关键点:只有注入进来的 **完整 40 位 SHA** 才算可用身份 —— 页面据此做精确匹配;
// 拿不到就明确返回 identity_available=false,由页面显示"当前运行版本未知",**绝不猜**。
type BuildInfo struct {
ReleaseVersion string `json:"release_version"`
BuildKind string `json:"build_kind"`
CommitSHA *string `json:"commit_sha"`
DisplayVersion string `json:"display_version"`
BuiltAt string `json:"built_at"`
IdentityAvailable bool `json:"identity_available"`
}
// Snapshot 读当前运行产物的构建身份。
func Snapshot() BuildInfo {
info := BuildInfo{ReleaseVersion: ReleaseVersion, BuildKind: BuildKind, BuiltAt: BuildTime}
if IsRelease() {
info.BuildKind = "release"
} else {
info.BuildKind = "development"
}
if fullSHA.MatchString(CommitSHA) {
sha := CommitSHA
info.CommitSHA = &sha
info.DisplayVersion = DisplayVersion()
info.IdentityAvailable = true
return info
}
// 身份不可用:明确说"未知",不回上一正式版本号,也不假装是某个提交
info.DisplayVersion = "dev"
info.IdentityAvailable = false
return info
}
+337
View File
@@ -0,0 +1,337 @@
package versionhistory
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// GiteaConfig 是只读客户端的配置。**只用匿名请求**:本客户端不会携带任何凭据,
// 也不接受调用方传入任意 URL —— base 必须在白名单里,仓库只允许配置的那一个。
type GiteaConfig struct {
BaseURL string
AllowedBaseURLs []string
Repo string
HTTPClient *http.Client
Timeout time.Duration
MaxResponseBytes int64
MaxRetries int
Sleep func(time.Duration)
PageSize int
}
// GiteaClient 只读地访问 Gitea 的提交、tag 与仓库内文件。
type GiteaClient struct {
baseURL *url.URL
repo string
client *http.Client
maxBytes int64
maxRetries int
sleep func(time.Duration)
pageSize int
}
const (
giteaDefaultTimeout = 15 * time.Second
giteaDefaultMaxBytes = 5 << 20 // 单个响应上限 5MB
giteaDefaultRetries = 3
giteaDefaultPageSize = 50 // Gitea 的 commits/tags 上限就是 50
giteaCommitURLSuffix = "/commit/"
)
// NewGiteaClient 校验并构造客户端。校验失败一律返回错误(宁可不接线)。
func NewGiteaClient(config GiteaConfig) (*GiteaClient, error) {
base, err := url.Parse(strings.TrimSpace(config.BaseURL))
if err != nil || base.Host == "" || (base.Scheme != "http" && base.Scheme != "https") {
return nil, errors.New("版本记录:Gitea base URL 非法(必须是 http(s) 绝对地址)")
}
allowed := false
for _, candidate := range config.AllowedBaseURLs {
parsed, perr := url.Parse(strings.TrimSpace(candidate))
if perr == nil && parsed.Host != "" && parsed.Scheme == base.Scheme && parsed.Host == base.Host {
allowed = true
break
}
}
if !allowed {
return nil, fmt.Errorf("版本记录:Gitea base URL %q 不在白名单里(拒绝任意 URL 请求)", config.BaseURL)
}
repo := strings.Trim(strings.TrimSpace(config.Repo), "/")
if strings.Count(repo, "/") != 1 || strings.ContainsAny(repo, " \t?#") {
return nil, errors.New("版本记录:仓库必须是 owner/name 形式")
}
maxBytes := config.MaxResponseBytes
if maxBytes <= 0 {
maxBytes = giteaDefaultMaxBytes
}
timeout := config.Timeout
if timeout <= 0 {
timeout = giteaDefaultTimeout
}
retries := config.MaxRetries
if retries <= 0 {
retries = giteaDefaultRetries
}
pageSize := config.PageSize
if pageSize <= 0 || pageSize > giteaDefaultPageSize {
pageSize = giteaDefaultPageSize
}
client := config.HTTPClient
if client == nil {
client = &http.Client{Timeout: timeout}
}
// 只允许回到同一个 host:重定向到别处一律拒绝(防 SSRF)。
sameHost := *client
sameHost.CheckRedirect = func(req *http.Request, via []*http.Request) error {
if req.URL.Scheme != base.Scheme || req.URL.Host != base.Host {
return newGiteaError(KindRedirect, 0, "版本记录:拒绝重定向到其它主机", nil)
}
return nil
}
sleep := config.Sleep
if sleep == nil {
sleep = time.Sleep
}
return &GiteaClient{baseURL: base, repo: repo, client: &sameHost, maxBytes: maxBytes,
maxRetries: retries, sleep: sleep, pageSize: pageSize}, nil
}
// PageSize 是抓取页大小(外层据此做完整性对账)。
func (c *GiteaClient) PageSize() int { return c.pageSize }
// CommitURLBase 给记录页拼提交链接用(白名单 origin + 固定仓库,不接受外部传入)。
func (c *GiteaClient) CommitURLBase() string {
return strings.TrimRight(c.baseURL.String(), "/") + "/" + c.repo + giteaCommitURLSuffix
}
func (c *GiteaClient) apiURL(path string, query url.Values) string {
target := *c.baseURL
target.Path = strings.TrimRight(c.baseURL.Path, "/") + "/api/v1/repos/" + c.repo + path
target.RawQuery = query.Encode()
return target.String()
}
// get 发一次匿名 GET(**不设 Authorization 头**),带超时、响应上限与退避重试。
func (c *GiteaClient) get(ctx context.Context, rawURL string) ([]byte, http.Header, error) {
var lastErr error
for attempt := 0; attempt <= c.maxRetries; attempt++ {
if attempt > 0 {
c.sleep(time.Duration(attempt) * 500 * time.Millisecond)
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return nil, nil, newGiteaError(KindParse, 0, "版本记录:构造请求失败", err)
}
request.Header.Set("Accept", "application/json")
response, err := c.client.Do(request)
if err != nil {
lastErr = newGiteaError(KindUnreachable, 0, "版本记录:Gitea 不可达", err)
continue
}
body, readErr := io.ReadAll(io.LimitReader(response.Body, c.maxBytes+1))
closeErr := response.Body.Close()
if readErr != nil {
lastErr = newGiteaError(KindUnreachable, 0, "版本记录:读取响应失败", readErr)
continue
}
if closeErr != nil {
lastErr = newGiteaError(KindUnreachable, 0, "版本记录:关闭响应失败", closeErr)
continue
}
if int64(len(body)) > c.maxBytes {
return nil, nil, newGiteaError(KindTooLarge, response.StatusCode,
fmt.Sprintf("版本记录:响应超过 %d 字节上限(已丢弃)", c.maxBytes), nil)
}
switch {
case response.StatusCode == http.StatusOK:
return body, response.Header, nil
case response.StatusCode == http.StatusNotFound:
return nil, response.Header, errGiteaNotFound
case response.StatusCode == http.StatusTooManyRequests || response.StatusCode >= 500:
lastErr = newGiteaError(KindStatus, response.StatusCode, "版本记录:Gitea 返回可重试错误", nil)
continue
default:
return nil, response.Header, newGiteaError(KindStatus, response.StatusCode, "版本记录:Gitea 返回错误", nil)
}
}
if lastErr == nil {
lastErr = newGiteaError(KindUnreachable, 0, "版本记录:Gitea 请求失败", nil)
}
return nil, nil, lastErr
}
var errGiteaNotFound = newGiteaError(KindNotFound, http.StatusNotFound, "版本记录:Gitea 上没有该对象", nil)
// ListCommits 读一页提交;同时返回 X-Total-Count(用于完整性对账,不做假 total)。
func (c *GiteaClient) ListCommits(ctx context.Context, page, limit int) ([]RawCommit, int, error) {
if limit <= 0 || limit > giteaDefaultPageSize {
limit = giteaDefaultPageSize
}
query := url.Values{}
query.Set("limit", fmt.Sprintf("%d", limit))
query.Set("page", fmt.Sprintf("%d", page))
query.Set("stat", "false")
query.Set("verification", "false")
body, header, err := c.get(ctx, c.apiURL("/commits", query))
if err != nil {
return nil, 0, err
}
var payload []struct {
SHA string `json:"sha"`
Parents []struct {
SHA string `json:"sha"`
} `json:"parents"`
Created string `json:"created"`
Commit struct {
Message string `json:"message"`
Author struct {
Name string `json:"name"`
Date string `json:"date"`
} `json:"author"`
Committer struct {
Name string `json:"name"`
Date string `json:"date"`
} `json:"committer"`
} `json:"commit"`
}
if err := json.Unmarshal(body, &payload); err != nil {
return nil, 0, newGiteaError(KindParse, 0, "版本记录:提交响应无法解析", err)
}
total := 0
if raw := header.Get("X-Total-Count"); raw != "" {
if _, err := fmt.Sscanf(raw, "%d", &total); err != nil {
total = 0
}
}
commits := make([]RawCommit, 0, len(payload))
for _, item := range payload {
if !fullSHA.MatchString(item.SHA) {
continue
}
parents := make([]string, 0, len(item.Parents))
for _, parent := range item.Parents {
parents = append(parents, parent.SHA)
}
subject, body := splitMessage(item.Commit.Message)
when := parseGiteaTime(item.Commit.Committer.Date)
if when.IsZero() {
when = parseGiteaTime(item.Commit.Author.Date)
}
if when.IsZero() {
when = parseGiteaTime(item.Created)
}
author := item.Commit.Author.Name
if author == "" {
author = item.Commit.Committer.Name
}
commits = append(commits, RawCommit{SHA: item.SHA, Parents: parents, CommittedAt: when,
AuthorDisplay: author, Subject: subject, Body: body})
}
return commits, total, nil
}
// BranchHeadSHA 读分支 HEAD 的完整 SHA(同步记录里的 source_head_sha 用它)。
func (c *GiteaClient) BranchHeadSHA(ctx context.Context, branch string) (string, error) {
if strings.ContainsAny(branch, " /?#") || branch == "" {
return "", newGiteaError(KindParse, 0, "版本记录:分支名非法", nil)
}
body, _, err := c.get(ctx, c.apiURL("/branches/"+branch, url.Values{}))
if err != nil {
return "", err
}
var payload struct {
Commit struct {
ID string `json:"id"`
} `json:"commit"`
}
if err := json.Unmarshal(body, &payload); err != nil {
return "", newGiteaError(KindParse, 0, "版本记录:分支响应无法解析", err)
}
if !fullSHA.MatchString(payload.Commit.ID) {
return "", newGiteaError(KindParse, 0, "版本记录:分支 HEAD 不是完整 SHA", nil)
}
return payload.Commit.ID, nil
}
// ListTags 读全部 tag,并逐个核验它是不是**注释 tag** 以及它指向哪个提交。
// 轻量 tag(object.type == "commit")不会被当作正式版本的证据。
func (c *GiteaClient) ListTags(ctx context.Context) ([]RawTag, error) {
type ref struct {
Ref string `json:"ref"`
Object struct {
Type string `json:"type"`
SHA string `json:"sha"`
} `json:"object"`
}
refs := make([]ref, 0)
for page := 1; page <= 40; page++ {
query := url.Values{}
query.Set("limit", "50")
query.Set("page", fmt.Sprintf("%d", page))
body, _, err := c.get(ctx, c.apiURL("/git/refs/tags", query))
if err != nil {
return nil, err
}
var pageRefs []ref
if err := json.Unmarshal(body, &pageRefs); err != nil {
return nil, newGiteaError(KindParse, 0, "版本记录:tag 列表无法解析", err)
}
refs = append(refs, pageRefs...)
// 短页 = 最后一页(也防住"服务端忽略 page 参数"导致的死循环)
if len(pageRefs) < 50 {
break
}
}
tags := make([]RawTag, 0, len(refs))
for _, item := range refs {
name := strings.TrimPrefix(item.Ref, "refs/tags/")
if name == "" {
continue
}
tag := RawTag{Name: name, TargetSHA: item.Object.SHA}
if item.Object.Type == "tag" {
tag.Annotated = true
tag.TagSHA = item.Object.SHA
// 注释 tag:解引用到真正的提交(tag 对象里的 object.sha)
body, _, err := c.get(ctx, c.apiURL("/git/tags/"+item.Object.SHA, url.Values{}))
if err != nil {
return nil, err
}
var annotated struct {
Object struct {
SHA string `json:"sha"`
} `json:"object"`
}
if err := json.Unmarshal(body, &annotated); err != nil {
return nil, newGiteaError(KindParse, 0, "版本记录:注释 tag 无法解析", err)
}
if !fullSHA.MatchString(annotated.Object.SHA) {
continue
}
tag.TargetSHA = annotated.Object.SHA
}
tags = append(tags, tag)
}
return tags, nil
}
// ReadFile 读某个提交内的文件(用于取该提交的 CHANGELOG.md)。
func (c *GiteaClient) ReadFile(ctx context.Context, sha, path string) (string, error) {
if !fullSHA.MatchString(sha) {
return "", newGiteaError(KindParse, 0, "版本记录:提交必须是完整 40 位 SHA", nil)
}
if strings.Contains(path, "..") || strings.HasPrefix(path, "/") {
return "", newGiteaError(KindParse, 0, "版本记录:文件路径非法", nil)
}
body, _, err := c.get(ctx, c.apiURL("/raw/"+path, url.Values{"ref": []string{sha}}))
if err != nil {
return "", err
}
return string(body), nil
}
+214
View File
@@ -0,0 +1,214 @@
package versionhistory
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
const (
shaA = "1111111111111111111111111111111111111111"
shaB = "2222222222222222222222222222222222222222"
shaT = "3333333333333333333333333333333333333333" // 注释 tag 对象
)
func fakeGitea(t *testing.T, handler http.HandlerFunc) *httptest.Server {
t.Helper()
server := httptest.NewServer(handler)
t.Cleanup(server.Close)
return server
}
func validConfig(base string) GiteaConfig {
return GiteaConfig{BaseURL: base, AllowedBaseURLs: []string{base}, Repo: "Weknora/knowledge-governance",
Sleep: func(time.Duration) {}, MaxRetries: 2, PageSize: 50}
}
func TestGiteaClientRejectsNonWhitelistedOriginAndBadRepo(t *testing.T) {
if _, err := NewGiteaClient(GiteaConfig{BaseURL: "http://evil.example.com",
AllowedBaseURLs: []string{"http://192.168.50.223:3000"}, Repo: "a/b"}); err == nil {
t.Fatal("白名单外的 origin 必须拒绝")
}
if _, err := NewGiteaClient(GiteaConfig{BaseURL: "ftp://host", AllowedBaseURLs: []string{"ftp://host"},
Repo: "a/b"}); err == nil {
t.Fatal("非 http(s) 必须拒绝")
}
if _, err := NewGiteaClient(GiteaConfig{BaseURL: "http://host", AllowedBaseURLs: []string{"http://host"},
Repo: "a/b/c"}); err == nil {
t.Fatal("仓库必须是 owner/name")
}
if _, err := NewGiteaClient(GiteaConfig{BaseURL: "http://host", AllowedBaseURLs: []string{"http://host"},
Repo: "a/b?x=1"}); err == nil {
t.Fatal("仓库名带查询串必须拒绝")
}
}
func TestGiteaClientListCommitsParsesFieldsAndSendsNoCredentials(t *testing.T) {
var sawAuth string
server := fakeGitea(t, func(w http.ResponseWriter, r *http.Request) {
sawAuth = r.Header.Get("Authorization")
if got := r.URL.Path; got != "/api/v1/repos/Weknora/knowledge-governance/commits" {
t.Errorf("路径 = %s", got)
}
if r.URL.Query().Get("limit") != "50" || r.URL.Query().Get("page") != "1" {
t.Errorf("分页参数 = %s", r.URL.RawQuery)
}
w.Header().Set("X-Total-Count", "111")
fmt.Fprintf(w, `[{"sha":%q,"parents":[{"sha":%q}],"created":"2026-10-13T09:12:00Z",
"commit":{"message":"feat: 标题\n\n正文一行\n第二行","author":{"name":"姚章浩","date":"2026-10-13T09:11:00Z"},
"committer":{"name":"姚章浩","date":"2026-10-13T09:12:00Z"}}}]`, shaB, shaA)
})
client, err := NewGiteaClient(validConfig(server.URL))
if err != nil {
t.Fatalf("构造客户端失败:%v", err)
}
commits, total, err := client.ListCommits(context.Background(), 1, 50)
if err != nil {
t.Fatalf("ListCommits:%v", err)
}
if sawAuth != "" {
t.Fatalf("匿名只读不得携带任何凭据,实际带了 Authorization=%q", sawAuth)
}
if total != 111 || len(commits) != 1 {
t.Fatalf("total=%d len=%d", total, len(commits))
}
got := commits[0]
if got.SHA != shaB || len(got.Parents) != 1 || got.Parents[0] != shaA {
t.Fatalf("提交字段解析错:%+v", got)
}
if got.Subject != "feat: 标题" || got.Body != "正文一行\n第二行" {
t.Fatalf("标题/正文拆分错:%q / %q", got.Subject, got.Body)
}
if !got.CommittedAt.Equal(time.Date(2026, 10, 13, 9, 12, 0, 0, time.UTC)) {
t.Fatalf("提交时间解析错:%s", got.CommittedAt)
}
if !strings.HasSuffix(client.CommitURLBase(), "/Weknora/knowledge-governance/commit/") {
t.Fatalf("提交链接基址错:%s", client.CommitURLBase())
}
}
func TestGiteaClientRetriesTransientFailuresThenGivesUp(t *testing.T) {
attempts := 0
server := fakeGitea(t, func(w http.ResponseWriter, r *http.Request) {
attempts++
if attempts < 3 {
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Header().Set("X-Total-Count", "0")
fmt.Fprint(w, "[]")
})
client, err := NewGiteaClient(validConfig(server.URL))
if err != nil {
t.Fatalf("构造客户端失败:%v", err)
}
if _, _, err := client.ListCommits(context.Background(), 1, 50); err != nil {
t.Fatalf("5xx 应退避重试后成功:%v", err)
}
if attempts != 3 {
t.Fatalf("重试次数 = %d,期望 3", attempts)
}
alwaysFail := fakeGitea(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
})
client2, err := NewGiteaClient(validConfig(alwaysFail.URL))
if err != nil {
t.Fatalf("构造客户端失败:%v", err)
}
if _, _, err := client2.ListCommits(context.Background(), 1, 50); err == nil {
t.Fatal("持续 5xx 必须报错,不能返回空数据")
}
}
func TestGiteaClientRejectsOversizedResponseAndCrossHostRedirect(t *testing.T) {
big := fakeGitea(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Total-Count", "1")
fmt.Fprintf(w, "[{\"sha\":%q,\"commit\":{\"message\":%q}}]", shaA, strings.Repeat("x", 4096))
})
config := validConfig(big.URL)
config.MaxResponseBytes = 512
client, err := NewGiteaClient(config)
if err != nil {
t.Fatalf("构造客户端失败:%v", err)
}
if _, _, err := client.ListCommits(context.Background(), 1, 50); err == nil {
t.Fatal("超过响应上限必须报错(丢弃)")
}
elsewhere := fakeGitea(t, func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, "[]") })
redirector := fakeGitea(t, func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, elsewhere.URL+"/api/v1/repos/Weknora/knowledge-governance/commits", http.StatusFound)
})
client2, err := NewGiteaClient(validConfig(redirector.URL))
if err != nil {
t.Fatalf("构造客户端失败:%v", err)
}
if _, _, err := client2.ListCommits(context.Background(), 1, 50); err == nil {
t.Fatal("跨主机重定向必须拒绝")
}
}
func TestGiteaClientListTagsDetectsAnnotatedAndLightweight(t *testing.T) {
server := fakeGitea(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/v1/repos/Weknora/knowledge-governance/git/refs/tags":
fmt.Fprintf(w, `[{"ref":"refs/tags/v0.1.0","object":{"type":"tag","sha":%q}},
{"ref":"refs/tags/light","object":{"type":"commit","sha":%q}}]`, shaT, shaA)
case r.URL.Path == "/api/v1/repos/Weknora/knowledge-governance/git/tags/"+shaT:
fmt.Fprintf(w, `{"tag":"v0.1.0","object":{"type":"commit","sha":%q}}`, shaB)
default:
t.Errorf("未预期的路径 %s", r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
})
client, err := NewGiteaClient(validConfig(server.URL))
if err != nil {
t.Fatalf("构造客户端失败:%v", err)
}
tags, err := client.ListTags(context.Background())
if err != nil {
t.Fatalf("ListTags:%v", err)
}
if len(tags) != 2 {
t.Fatalf("tag 数 = %d", len(tags))
}
annotated := tags[0]
if annotated.Name != "v0.1.0" || !annotated.Annotated || annotated.TagSHA != shaT || annotated.TargetSHA != shaB {
t.Fatalf("注释 tag 应解引用到提交:%+v", annotated)
}
lightweight := tags[1]
if lightweight.Annotated || lightweight.TargetSHA != shaA {
t.Fatalf("轻量 tag 不能被当成注释 tag:%+v", lightweight)
}
}
func TestGiteaClientReadFileGuards(t *testing.T) {
server := fakeGitea(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("ref") != shaA {
t.Errorf("ref = %s", r.URL.Query().Get("ref"))
}
fmt.Fprint(w, "# Changelog\n")
})
client, err := NewGiteaClient(validConfig(server.URL))
if err != nil {
t.Fatalf("构造客户端失败:%v", err)
}
text, err := client.ReadFile(context.Background(), shaA, "CHANGELOG.md")
if err != nil || !strings.Contains(text, "Changelog") {
t.Fatalf("读文件失败:%v / %q", err, text)
}
if _, err := client.ReadFile(context.Background(), "abc", "CHANGELOG.md"); err == nil {
t.Fatal("短 SHA 必须拒绝")
}
if _, err := client.ReadFile(context.Background(), shaA, "../../etc/passwd"); err == nil {
t.Fatal("路径穿越必须拒绝")
}
if _, err := client.ReadFile(context.Background(), shaA, "/etc/passwd"); err == nil {
t.Fatal("绝对路径必须拒绝")
}
}
+36
View File
@@ -0,0 +1,36 @@
package versionhistory
import (
"regexp"
"strings"
"time"
)
// fullSHA:只认完整 40 位小写十六进制 —— 版本记录的唯一身份。
var fullSHA = regexp.MustCompile(`^[0-9a-f]{40}$`)
// splitMessage 把提交信息拆成标题(首行)与正文(其余);不编造摘要。
func splitMessage(message string) (subject, body string) {
message = strings.ReplaceAll(message, "\r\n", "\n")
trimmed := strings.TrimRight(message, "\n")
if strings.TrimSpace(trimmed) == "" {
return "", ""
}
if index := strings.Index(trimmed, "\n"); index >= 0 {
return strings.TrimSpace(trimmed[:index]), strings.TrimSpace(trimmed[index+1:])
}
return strings.TrimSpace(trimmed), ""
}
// parseGiteaTime 解析 Gitea 的时间字段;解析不了返回零值(调用方按"时间未知"处理)。
func parseGiteaTime(value string) time.Time {
if strings.TrimSpace(value) == "" {
return time.Time{}
}
for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02T15:04:05Z0700"} {
if parsed, err := time.Parse(layout, value); err == nil {
return parsed.UTC()
}
}
return time.Time{}
}
+168
View File
@@ -0,0 +1,168 @@
package versionhistory
import (
"context"
"os"
"os/exec"
"strings"
"testing"
"time"
)
// 真实仓库的只读同步用例:**匿名抓取**内部可达的 Gitea,并把算出的版本号与本地 git 交叉核对
// (`git rev-list --count v0.1.0..<sha>`)—— 两套实现(Go 图遍历 vs git)必须给出同一个号。
//
// 未设 VERSION_HISTORY_LIVE_BASE_URL 即按设计 SKIP(已在 scripts/test-scope.json 声明理由);
// 本用例**不读任何凭据**、只读远端仓库,不写任何东西。
func TestLiveSyncAgainstRealRepository(t *testing.T) {
base := strings.TrimSpace(os.Getenv("VERSION_HISTORY_LIVE_BASE_URL"))
if base == "" {
t.Skip("VERSION_HISTORY_LIVE_BASE_URL 未设置:跳过真实仓库的只读同步用例")
}
if _, err := exec.LookPath("git"); err != nil {
t.Skip("本地没有 git:无法与 git 交叉核对")
}
repo := strings.TrimSpace(os.Getenv("VERSION_HISTORY_LIVE_REPO"))
if repo == "" {
repo = "Weknora/knowledge-governance"
}
client, err := NewGiteaClient(GiteaConfig{BaseURL: base, AllowedBaseURLs: []string{base}, Repo: repo})
if err != nil {
t.Fatalf("构造只读客户端:%v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
defer cancel()
commits, total := []RawCommit{}, 0
for page := 1; page <= 10; page++ {
pageCommits, pageTotal, err := client.ListCommits(ctx, page, 50)
if err != nil {
t.Fatalf("第 %d 页抓取失败:%v", page, err)
}
if pageTotal > 0 {
total = pageTotal
}
commits = append(commits, pageCommits...)
if len(pageCommits) < 50 {
break
}
}
seen := map[string]bool{}
for _, commit := range commits {
if seen[commit.SHA] {
t.Fatalf("分页出现重复提交 %s", commit.SHA)
}
seen[commit.SHA] = true
}
if total == 0 || len(commits) != total {
t.Fatalf("分页与 X-Total-Count 不一致:抓到 %d,远端宣称 %d", len(commits), total)
}
tags, err := client.ListTags(ctx)
if err != nil {
t.Fatalf("读 tag:%v", err)
}
// 与本地 git 交叉核对:注释 tag 指向的提交必须与 `git rev-parse` 一致
var releaseTag RawTag
foundRelease := false
for _, tag := range tags {
if tag.Name == "v0.1.0" {
releaseTag, foundRelease = tag, true
}
}
if !foundRelease || !releaseTag.Annotated {
t.Fatalf("真实仓库里应有注释 tag v0.1.0,实际:%+v", tags)
}
if local := localGit(t, "rev-parse", "v0.1.0^{commit}"); local != releaseTag.TargetSHA {
t.Fatalf("tag 目标与本地 git 不一致:接口 %s,本地 %s", releaseTag.TargetSHA, local)
}
derived := DeriveVersions(DeriveInput{RepoKey: repo, CommitURLBase: client.CommitURLBase(),
Commits: commits, Tags: tags,
Changelog: func(sha string) (string, error) { return client.ReadFile(ctx, sha, "CHANGELOG.md") }})
if !derived.GraphComplete {
t.Fatalf("真实仓库的提交图应当完整,缺父提交 %v", derived.MissingParents)
}
releases, development, historical := 0, 0, 0
for _, record := range derived.Records {
switch record.RecordKind {
case "release":
releases++
if record.CommitSHA == releaseTag.TargetSHA {
if record.VersionLabel != "0.1.0" || record.ReleaseNotes == "" {
t.Fatalf("正式版本记录不完整:%+v", record)
}
}
case "development":
development++
case "historical":
historical++
if record.VersionLabel != "" {
t.Fatalf("历史提交不许有版本号:%+v", record)
}
}
}
if releases != 1 {
t.Fatalf("正式版本条数 = %d,期望 1(只有 v0.1.0)", releases)
}
// 交叉核对开发号:Go 的图遍历 vs 本地 git 的 rev-list --count
checked := 0
for _, record := range derived.Records {
if record.RecordKind != "development" || checked >= 8 {
continue
}
if _, err := exec.Command("git", "cat-file", "-e", record.CommitSHA).Output(); err != nil {
continue // 本地没有这个提交(远端有新提交)⇒ 跳过交叉核对
}
count := localGit(t, "rev-list", "--count", "v0.1.0.."+record.CommitSHA)
want := "0.1." + count
if record.VersionLabel != want {
t.Fatalf("提交 %s 的号 = %q,git 算出来是 %q", record.CommitSHA[:12], record.VersionLabel, want)
}
checked++
}
if checked == 0 {
t.Fatal("没有交叉核对任何开发号 —— 证据不足")
}
head := derived.Records[0]
t.Logf("真实仓库 %s:远端 main 可达 %d 条提交(对账一致)|正式 %d|开发 %d|历史 %d|tag %d 个(注释 %v 个)",
repo, len(commits), releases, development, historical, len(tags), countAnnotated(tags))
for index, record := range derived.Records {
if index >= 6 {
break
}
label := record.VersionLabel
if label == "" {
label = "历史提交 · " + record.CommitSHA[:12]
}
t.Logf(" %s|%s|%s|%s", label, record.CommitSHA[:12], record.RecordKind, record.Subject)
}
if head.VersionLabel == "" && head.RecordKind != "historical" {
t.Fatalf("HEAD 记录异常:%+v", head)
}
if want := "0.1." + localGit(t, "rev-list", "--count", "v0.1.0.."+head.CommitSHA); head.VersionLabel != want {
t.Fatalf("HEAD 的号 = %q,git 算出来是 %q", head.VersionLabel, want)
}
}
func countAnnotated(tags []RawTag) int {
annotated := 0
for _, tag := range tags {
if tag.Annotated {
annotated++
}
}
return annotated
}
func localGit(t *testing.T, args ...string) string {
t.Helper()
output, err := exec.Command("git", args...).Output()
if err != nil {
t.Fatalf("本地 git %s 失败:%v", strings.Join(args, " "), err)
}
return strings.TrimSpace(string(output))
}
+190
View File
@@ -0,0 +1,190 @@
// Package versionhistory 把「远端 main 可达的提交」+「tag 证据」+「提交内 CHANGELOG」
// 换算成版本记录页要的提交记录。
//
// 它**只读、纯计算**:不发网络请求、不写库、不执行任何历史提交里的脚本。版本号口径
// 委托给 internal/governance.DecideVersion,提交数按提交图算(等价 `git rev-list --count <tag>..<sha>`),
// 与 scripts/version.py 同规则 —— 两处算出的号必须一致。
package versionhistory
import (
"sort"
"time"
"knowledge-governance/internal/governance"
)
// RawCommit 是一条从 Gitea 读回的提交(只保留展示与判版本所需的字段)。
type RawCommit struct {
SHA string
Parents []string
CommittedAt time.Time
AuthorDisplay string
Subject string
Body string
}
// RawTag 是一个 tag 及其可核验属性;Annotated=false 表示轻量 tag(不算正式版本的证据)。
type RawTag struct {
Name string
TagSHA string
TargetSHA string
Annotated bool
}
// ChangelogSource 读「某个提交内的 CHANGELOG.md」;读不到返回错误(调用方按"无记录"处理)。
type ChangelogSource func(sha string) (string, error)
// DeriveInput 是一次换算的全部输入。
type DeriveInput struct {
RepoKey string
CommitURLBase string // 形如 "<origin>/<owner>/<repo>/commit/",由配置拼好,不接受外部传入
Commits []RawCommit
Tags []RawTag
Changelog ChangelogSource
SnapshotID int64
}
// DeriveResult 是换算结果。
type DeriveResult struct {
Records []governance.VersionHistoryCommit
GraphComplete bool
MissingParents []string
}
// DeriveVersions 逐提交判定版本号(含合并提交与侧分支提交;不展示任何未推送的本地提交,
// 因为输入本身就是远端 main 可达集合)。
func DeriveVersions(in DeriveInput) DeriveResult {
bySHA := make(map[string]RawCommit, len(in.Commits))
for _, commit := range in.Commits {
bySHA[commit.SHA] = commit
}
missing := map[string]bool{}
ancestors := make(map[string]map[string]struct{}, len(in.Commits))
var walk func(sha string) map[string]struct{}
walk = func(sha string) map[string]struct{} {
if set, ok := ancestors[sha]; ok {
return set
}
set := map[string]struct{}{sha: {}}
ancestors[sha] = set // 先占位,DAG 不会有环
commit, ok := bySHA[sha]
if !ok {
return set
}
for _, parent := range commit.Parents {
if _, known := bySHA[parent]; !known {
missing[parent] = true
continue
}
for item := range walk(parent) {
set[item] = struct{}{}
}
}
return set
}
for _, commit := range in.Commits {
walk(commit.SHA)
}
tagsByTarget := map[string][]RawTag{}
tagByName := map[string]RawTag{}
for _, tag := range in.Tags {
tagsByTarget[tag.TargetSHA] = append(tagsByTarget[tag.TargetSHA], tag)
if _, exists := tagByName[tag.Name]; !exists {
tagByName[tag.Name] = tag
}
}
changelogCache := map[string]string{}
readChangelog := func(sha string) string {
if text, ok := changelogCache[sha]; ok {
return text
}
text := ""
if in.Changelog != nil {
if loaded, err := in.Changelog(sha); err == nil {
text = loaded
}
}
changelogCache[sha] = text
return text
}
graphComplete := len(missing) == 0
result := DeriveResult{GraphComplete: graphComplete}
for sha := range missing {
result.MissingParents = append(result.MissingParents, sha)
}
sort.Strings(result.MissingParents)
records := make([]governance.VersionHistoryCommit, 0, len(in.Commits))
for _, commit := range in.Commits {
changelog := readChangelog(commit.SHA)
top := governance.ChangelogTopVersion(changelog)
baseTagName := ""
var sinceBase *int
if governance.SemverValid(top) {
candidate := "v" + top
if base, exists := tagByName[candidate]; exists {
if !graphComplete {
// tag 存在但图不完整 ⇒ 不给号(宁可没号,也不报错号)
baseTagName = candidate
} else if baseSet, ok := ancestors[base.TargetSHA]; ok {
if own, ok2 := ancestors[commit.SHA]; ok2 {
count := 0
for item := range own {
if _, inBase := baseSet[item]; !inBase {
count++
}
}
baseTagName = candidate
sinceBase = &count
}
}
}
}
evidenceTags := make([]governance.VersionTagEvidence, 0, len(tagsByTarget[commit.SHA]))
for _, tag := range tagsByTarget[commit.SHA] {
evidenceTags = append(evidenceTags, governance.VersionTagEvidence{
Name: tag.Name, TagSHA: tag.TagSHA, Annotated: tag.Annotated})
}
sort.Slice(evidenceTags, func(i, j int) bool { return evidenceTags[i].Name < evidenceTags[j].Name })
label, state, kind, evidence := governance.DecideVersion(top, evidenceTags, baseTagName, sinceBase)
if label == "" && !graphComplete {
evidence.Reason = governance.ReasonIncompleteGraph
}
record := governance.VersionHistoryCommit{
RepoKey: in.RepoKey,
CommitSHA: commit.SHA,
Parents: commit.Parents,
CommittedAt: commit.CommittedAt,
AuthorDisplay: commit.AuthorDisplay,
Subject: commit.Subject,
Body: commit.Body,
VersionLabel: label,
VersionState: state,
RecordKind: kind,
ReleaseTags: evidenceTags,
CommitURL: in.CommitURLBase + commit.SHA,
VersionEvidence: evidence,
SnapshotID: in.SnapshotID,
}
if kind == governance.RecordKindRelease {
record.ReleaseNotes = governance.ExtractReleaseNotes(changelog, label)
}
records = append(records, record)
}
// 最新在上;同一提交时间用 SHA 兜底 ⇒ 排序确定,分页不重不漏。
sort.SliceStable(records, func(i, j int) bool {
if !records[i].CommittedAt.Equal(records[j].CommittedAt) {
return records[i].CommittedAt.After(records[j].CommittedAt)
}
return records[i].CommitSHA > records[j].CommitSHA
})
result.Records = records
return result
}
+195
View File
@@ -0,0 +1,195 @@
package versionhistory
import (
"testing"
"time"
"knowledge-governance/internal/governance"
)
func at(minute int) time.Time {
return time.Date(2026, 10, 13, 10, minute, 0, 0, time.UTC)
}
func commit(sha string, parents []string, minute int) RawCommit {
return RawCommit{SHA: sha, Parents: parents, CommittedAt: at(minute),
AuthorDisplay: "姚章浩", Subject: "feat: " + sha, Body: "body " + sha}
}
func changelogWith(top string) string {
if top == "" {
return "# Changelog\n\n## [Unreleased]\n\n### Added\n\n- 早期提交,还没有已发布版本\n"
}
return "# Changelog\n\n## [Unreleased]\n\n### Added\n\n- 新\n\n" +
"## [" + top + "] - 2026-10-01\n\n### Added\n\n- 首次发布\n"
}
func readerFor(topBySHA map[string]string) ChangelogSource {
return func(sha string) (string, error) { return changelogWith(topBySHA[sha]), nil }
}
func find(t *testing.T, records []governance.VersionHistoryCommit, sha string) governance.VersionHistoryCommit {
t.Helper()
for _, record := range records {
if record.CommitSHA == sha {
return record
}
}
t.Fatalf("记录里没有提交 %s", sha)
return governance.VersionHistoryCommit{}
}
func TestDeriveVersionsLinearHistoryWithAnnotatedTag(t *testing.T) {
commits := []RawCommit{
commit("A", nil, 1), commit("B", []string{"A"}, 2),
commit("C", []string{"B"}, 3), commit("D", []string{"C"}, 4),
}
tags := []RawTag{{Name: "v0.1.0", TagSHA: "tagobj", TargetSHA: "A", Annotated: true}}
top := map[string]string{"A": "0.1.0", "B": "0.1.0", "C": "0.1.0", "D": "0.1.0"}
result := DeriveVersions(DeriveInput{RepoKey: "Weknora/knowledge-governance",
CommitURLBase: "http://gitea/commit/", Commits: commits, Tags: tags, Changelog: readerFor(top)})
if !result.GraphComplete {
t.Fatal("线性历史应判为图完整")
}
if len(result.Records) != 4 {
t.Fatalf("记录数 = %d,期望 4", len(result.Records))
}
// 最新在上
if result.Records[0].CommitSHA != "D" || result.Records[3].CommitSHA != "A" {
t.Fatalf("排序不对:%s…%s", result.Records[0].CommitSHA, result.Records[3].CommitSHA)
}
for sha, want := range map[string]string{"A": "0.1.0", "B": "0.1.1", "C": "0.1.2", "D": "0.1.3"} {
record := find(t, result.Records, sha)
if record.VersionLabel != want {
t.Fatalf("%s 的号 = %q,期望 %q(原因 %s)", sha, record.VersionLabel, want, record.VersionEvidence.Reason)
}
if record.CommitURL != "http://gitea/commit/"+sha {
t.Fatalf("%s 的提交链接 = %q", sha, record.CommitURL)
}
}
release := find(t, result.Records, "A")
if release.RecordKind != governance.RecordKindRelease || release.VersionState != governance.VersionStateVerified {
t.Fatalf("tag 指向的提交应是正式版本:%+v", release)
}
if release.ReleaseNotes == "" || len(release.ReleaseTags) != 1 {
t.Fatalf("正式版本必须带 tag 与提交内 CHANGELOG 正文:%+v", release)
}
development := find(t, result.Records, "B")
if development.RecordKind != governance.RecordKindDevelopment || development.ReleaseNotes != "" {
t.Fatalf("开发提交不该带发布正文:%+v", development)
}
}
func TestDeriveVersionsKeepsMergeAndSideBranchCommitsAndSameNumberDifferentSHA(t *testing.T) {
commits := []RawCommit{
commit("A", nil, 1), commit("B", []string{"A"}, 2), commit("C", []string{"A"}, 3),
commit("M", []string{"B", "C"}, 4), commit("D", []string{"M"}, 5),
}
tags := []RawTag{{Name: "v0.1.0", TargetSHA: "A", Annotated: true}}
top := map[string]string{"A": "0.1.0", "B": "0.1.0", "C": "0.1.0", "M": "0.1.0", "D": "0.1.0"}
result := DeriveVersions(DeriveInput{RepoKey: "repo", Commits: commits, Tags: tags, Changelog: readerFor(top)})
for _, sha := range []string{"B", "C", "M", "D"} {
if record := find(t, result.Records, sha); record.VersionLabel == "" {
t.Fatalf("合并/侧分支提交 %s 必须能查到:%+v", sha, record)
}
}
if merge := find(t, result.Records, "M"); len(merge.Parents) != 2 || merge.VersionLabel != "0.1.3" {
t.Fatalf("合并提交应保留双父并按图算号:%+v", merge)
}
if side := find(t, result.Records, "C"); side.VersionLabel != "0.1.1" {
t.Fatalf("侧分支提交按同一基线算:%+v", side)
}
// 同号不同 SHA:B 与 C 都是 0.1.1,两条都要在(唯一身份是 SHA)
same := 0
for _, record := range result.Records {
if record.VersionLabel == "0.1.1" {
same++
}
}
if same != 2 {
t.Fatalf("同号不同 SHA 应保留两条,实际 %d", same)
}
if find(t, result.Records, "B").CommitSHA == find(t, result.Records, "C").CommitSHA {
t.Fatal("同号两条必须靠 SHA 区分")
}
}
func TestDeriveVersionsIncompleteGraphNeverInventsNumbers(t *testing.T) {
// 只拿到 B、C,父提交 A 不在集合里(等价浅克隆/检出截断)
commits := []RawCommit{commit("B", []string{"A"}, 2), commit("C", []string{"B"}, 3)}
tags := []RawTag{{Name: "v0.1.0", TargetSHA: "A", Annotated: true}}
top := map[string]string{"B": "0.1.0", "C": "0.1.0"}
result := DeriveVersions(DeriveInput{RepoKey: "repo", Commits: commits, Tags: tags, Changelog: readerFor(top)})
if result.GraphComplete {
t.Fatal("缺父提交必须判为图不完整")
}
if len(result.MissingParents) != 1 || result.MissingParents[0] != "A" {
t.Fatalf("缺失父提交 = %v", result.MissingParents)
}
for _, record := range result.Records {
if record.VersionLabel != "" {
t.Fatalf("图不完整时不许给号:%+v", record)
}
if record.VersionEvidence.Reason != governance.ReasonIncompleteGraph {
t.Fatalf("原因码应为 %s,得到 %s", governance.ReasonIncompleteGraph, record.VersionEvidence.Reason)
}
if record.RecordKind != governance.RecordKindHistorical {
t.Fatalf("应为历史提交:%+v", record)
}
}
}
func TestDeriveVersionsLightweightTagIsNotARelease(t *testing.T) {
commits := []RawCommit{commit("A", nil, 1)}
tags := []RawTag{{Name: "v0.1.0", TargetSHA: "A", Annotated: false}} // 轻量 tag
result := DeriveVersions(DeriveInput{RepoKey: "repo", Commits: commits, Tags: tags,
Changelog: readerFor(map[string]string{"A": "0.1.0"})})
record := find(t, result.Records, "A")
if record.RecordKind == governance.RecordKindRelease {
t.Fatalf("轻量 tag 不得标成正式版本:%+v", record)
}
if len(record.ReleaseTags) != 1 || record.ReleaseTags[0].Annotated {
t.Fatalf("tag 证据要原样保留:%+v", record.ReleaseTags)
}
}
func TestDeriveVersionsPicksUpTagAddedAfterwards(t *testing.T) {
commits := []RawCommit{commit("A", nil, 1), commit("B", []string{"A"}, 2)}
changelog := readerFor(map[string]string{"A": "0.1.0", "B": "0.1.0"})
before := DeriveVersions(DeriveInput{RepoKey: "repo", Commits: commits, Changelog: changelog})
if find(t, before.Records, "B").RecordKind != governance.RecordKindHistorical {
t.Fatalf("还没有 tag 时 B 只能是历史提交:%+v", find(t, before.Records, "B"))
}
// 旧提交后补的注释 tag:再算一次就该升级成正式版本相关记录(不新增第二行)
after := DeriveVersions(DeriveInput{RepoKey: "repo", Commits: commits, Changelog: changelog,
Tags: []RawTag{{Name: "v0.1.0", TargetSHA: "A", Annotated: true}}})
if find(t, after.Records, "A").RecordKind != governance.RecordKindRelease {
t.Fatalf("后补的注释 tag 必须让 A 变成正式版本:%+v", find(t, after.Records, "A"))
}
if len(after.Records) != len(before.Records) {
t.Fatalf("后补 tag 不得新增记录:%d → %d", len(before.Records), len(after.Records))
}
if find(t, after.Records, "B").VersionLabel != "0.1.1" {
t.Fatalf("B 应拿到开发号:%+v", find(t, after.Records, "B"))
}
}
func TestDeriveVersionsSortsDeterministicallyForPaging(t *testing.T) {
sameTime := at(9)
commits := []RawCommit{
{SHA: "aaa", CommittedAt: sameTime}, {SHA: "bbb", CommittedAt: sameTime},
{SHA: "ccc", CommittedAt: sameTime}, {SHA: "ddd", CommittedAt: sameTime},
}
result := DeriveVersions(DeriveInput{RepoKey: "repo", Commits: commits})
order := []string{result.Records[0].CommitSHA, result.Records[1].CommitSHA,
result.Records[2].CommitSHA, result.Records[3].CommitSHA}
want := []string{"ddd", "ccc", "bbb", "aaa"}
for i := range want {
if order[i] != want[i] {
t.Fatalf("同一提交时间的排序必须确定:%v", order)
}
}
}
+279
View File
@@ -0,0 +1,279 @@
package versionhistory
import (
"context"
"errors"
"fmt"
"sort"
"strings"
"time"
"knowledge-governance/internal/governance"
)
// 同步作业的错误码:页面与同步记录直接展示,**不暴露内部细节**。
const (
ErrorCodeUnreachable = "gitea_unreachable"
ErrorCodeStatus = "gitea_status"
ErrorCodeTooLarge = "gitea_response_too_large"
ErrorCodeNotFound = "gitea_not_found"
ErrorCodeParse = "gitea_parse"
ErrorCodeRedirect = "gitea_redirect"
ErrorCodeCountMismatch = "count_mismatch"
ErrorCodeIncompletePages = "incomplete_history"
ErrorCodeUnexpected = "unexpected"
)
// ErrorKind 是客户端错误的机器可读类别(同步器据此给 error_code,不去猜字符串)。
type ErrorKind string
const (
KindUnreachable ErrorKind = "unreachable"
KindStatus ErrorKind = "status"
KindTooLarge ErrorKind = "too_large"
KindNotFound ErrorKind = "not_found"
KindParse ErrorKind = "parse"
KindRedirect ErrorKind = "redirect"
)
// GiteaError 携带类别与 HTTP 状态码。
type GiteaError struct {
Kind ErrorKind
Status int
Message string
Wrapped error
}
func (e *GiteaError) Error() string {
if e.Status > 0 {
return fmt.Sprintf("%s(HTTP %d)", e.Message, e.Status)
}
return e.Message
}
func (e *GiteaError) Unwrap() error { return e.Wrapped }
func newGiteaError(kind ErrorKind, status int, message string, wrapped error) *GiteaError {
return &GiteaError{Kind: kind, Status: status, Message: message, Wrapped: wrapped}
}
// errorCode 把任意错误翻成同步错误码;未识别的错误归为 unexpected(不静默吞掉)。
func errorCode(err error) string {
var gitea *GiteaError
if errors.As(err, &gitea) {
switch gitea.Kind {
case KindUnreachable:
return ErrorCodeUnreachable
case KindStatus:
if gitea.Status > 0 {
return fmt.Sprintf("%s_%d", ErrorCodeStatus, gitea.Status)
}
return ErrorCodeStatus
case KindTooLarge:
return ErrorCodeTooLarge
case KindNotFound:
return ErrorCodeNotFound
case KindParse:
return ErrorCodeParse
case KindRedirect:
return ErrorCodeRedirect
}
}
var typed interface{ ErrorCode() string }
if errors.As(err, &typed) {
return typed.ErrorCode()
}
return ErrorCodeUnexpected
}
// codeError 让同步器自己的失败也带机器可读码。
type codeError struct {
code string
message string
}
func (e codeError) Error() string { return e.message }
func (e codeError) ErrorCode() string { return e.code }
// JobType 是同步作业的类型名:HTTP 侧入队与 worker 侧注册必须用同一个值。
const JobType = "versionhistory.sync"
// Source 是版本记录同步需要的只读数据源。
type Source interface {
PageSize() int
CommitURLBase() string
ListCommits(ctx context.Context, page, limit int) ([]RawCommit, int, error)
ListTags(ctx context.Context) ([]RawTag, error)
ReadFile(ctx context.Context, sha, path string) (string, error)
// BranchHeadSHA 读分支 HEAD;读不到不算致命(退回"提交列表里最新的那条")。
BranchHeadSHA(ctx context.Context, branch string) (string, error)
}
// SyncStore 是同步器需要的持久化能力。
type SyncStore interface {
BeginVersionHistorySync(ctx context.Context, repoKey, jobID, triggeredBy string, now time.Time) (int64, error)
SaveVersionHistorySnapshot(ctx context.Context, snapshotID int64, repoKey string,
records []governance.VersionHistoryCommit, expected int, complete bool, now time.Time) error
FinishVersionHistorySync(ctx context.Context, runID int64, outcome SyncOutcome, now time.Time) error
FailVersionHistorySync(ctx context.Context, runID int64, errorCode string, now time.Time) error
}
// SyncOutcome 是一次成功同步的结果(「同步成功」与「历史完整」分开表达)。
type SyncOutcome struct {
SourceHeadSHA string
FetchedCount int
ExpectedCount int
Complete bool
}
// SyncerConfig 装配同步器。
type SyncerConfig struct {
Source Source
Store SyncStore
RepoKey string
Changelog string // 提交内的变更记录文件路径,默认 CHANGELOG.md
Now func() time.Time
MaxPages int
}
// Syncer 抓取远端 main 可达的全部提交并换算版本记录。
type Syncer struct {
source Source
store SyncStore
repoKey string
changelog string
now func() time.Time
maxPages int
}
const syncerDefaultMaxPages = 200
// NewSyncer 校验依赖;缺一即拒绝装配(不允许"半接线"跑起来)。
func NewSyncer(config SyncerConfig) (*Syncer, error) {
if config.Source == nil || config.Store == nil {
return nil, errors.New("版本记录同步器:数据源与存储都必需")
}
if strings.TrimSpace(config.RepoKey) == "" {
return nil, errors.New("版本记录同步器:仓库标识必需")
}
changelog := config.Changelog
if changelog == "" {
changelog = "CHANGELOG.md"
}
now := config.Now
if now == nil {
now = time.Now
}
maxPages := config.MaxPages
if maxPages <= 0 {
maxPages = syncerDefaultMaxPages
}
return &Syncer{source: config.Source, store: config.Store, repoKey: config.RepoKey,
changelog: changelog, now: now, maxPages: maxPages}, nil
}
// Sync 跑一次同步。任何失败都会把这次 run 标成 failed 并带上错误码;
// **失败绝不切换对外快照**,页面继续看上一次成功的数据。
func (s *Syncer) Sync(ctx context.Context, jobID, triggeredBy string) (governance.VersionHistorySyncRun, error) {
now := s.now().UTC()
runID, err := s.store.BeginVersionHistorySync(ctx, s.repoKey, jobID, triggeredBy, now)
if err != nil {
return governance.VersionHistorySyncRun{}, fmt.Errorf("版本记录:开始同步失败:%w", err)
}
fail := func(cause error) (governance.VersionHistorySyncRun, error) {
code := errorCode(cause)
if markErr := s.store.FailVersionHistorySync(ctx, runID, code, s.now().UTC()); markErr != nil {
return governance.VersionHistorySyncRun{}, fmt.Errorf("版本记录:标记同步失败时又出错:%w", markErr)
}
return governance.VersionHistorySyncRun{ID: runID, Status: "failed", ErrorCode: code},
fmt.Errorf("版本记录:同步失败(%s):%w", code, cause)
}
commits, total, err := s.fetchAllCommits(ctx)
if err != nil {
return fail(err)
}
if total > 0 && len(commits) != total {
return fail(codeError{code: ErrorCodeCountMismatch,
message: fmt.Sprintf("版本记录:提交数与远端宣称不一致(抓到 %d,远端 X-Total-Count=%d)", len(commits), total)})
}
tags, err := s.source.ListTags(ctx)
if err != nil {
return fail(err)
}
derived := DeriveVersions(DeriveInput{RepoKey: s.repoKey, CommitURLBase: s.source.CommitURLBase(),
Commits: commits, Tags: tags, SnapshotID: runID,
Changelog: func(sha string) (string, error) {
return s.source.ReadFile(ctx, sha, s.changelog)
}})
if err := s.store.SaveVersionHistorySnapshot(ctx, runID, s.repoKey, derived.Records, total, true, s.now().UTC()); err != nil {
return fail(err)
}
sourceHead := ""
if head, err := s.source.BranchHeadSHA(ctx, "main"); err == nil {
sourceHead = head
} else {
sourceHead = headSHA(commits) // 读分支失败不致命:退回提交列表最新一条
}
outcome := SyncOutcome{SourceHeadSHA: sourceHead, FetchedCount: len(commits),
ExpectedCount: total, Complete: derived.GraphComplete && (total == 0 || len(commits) == total)}
if err := s.store.FinishVersionHistorySync(ctx, runID, outcome, s.now().UTC()); err != nil {
return fail(err)
}
return governance.VersionHistorySyncRun{ID: runID, Status: "completed", SourceHeadSHA: outcome.SourceHeadSHA,
FetchedCount: outcome.FetchedCount, ExpectedCount: &total, Complete: outcome.Complete,
LastSuccessAt: s.now().UTC(), StartedAt: now}, nil
}
// fetchAllCommits 逐页抓取并按 SHA 去重;短页即终止(也防住服务端忽略 page 参数)。
func (s *Syncer) fetchAllCommits(ctx context.Context) ([]RawCommit, int, error) {
pageSize := s.source.PageSize()
if pageSize <= 0 {
pageSize = giteaDefaultPageSize
}
seen := map[string]bool{}
commits := make([]RawCommit, 0, pageSize)
total := 0
for page := 1; page <= s.maxPages; page++ {
pageCommits, pageTotal, err := s.source.ListCommits(ctx, page, pageSize)
if err != nil {
return nil, 0, err
}
if pageTotal > 0 {
total = pageTotal
}
for _, commit := range pageCommits {
if seen[commit.SHA] {
continue
}
seen[commit.SHA] = true
commits = append(commits, commit)
}
if len(pageCommits) < pageSize {
return commits, total, nil
}
if total > 0 && len(commits) >= total {
return commits, total, nil
}
}
return nil, 0, codeError{code: ErrorCodeIncompletePages,
message: fmt.Sprintf("版本记录:分页超过 %d 页仍未取完(疑似服务端分页异常)", s.maxPages)}
}
// headSHA 取远端 main 的 HEAD(提交列表按时间倒序,最新一条即 HEAD;取不到就空)。
func headSHA(commits []RawCommit) string {
sorted := make([]RawCommit, len(commits))
copy(sorted, commits)
sort.Slice(sorted, func(i, j int) bool {
if !sorted[i].CommittedAt.Equal(sorted[j].CommittedAt) {
return sorted[i].CommittedAt.After(sorted[j].CommittedAt)
}
return sorted[i].SHA > sorted[j].SHA
})
if len(sorted) == 0 {
return ""
}
return sorted[0].SHA
}
+251
View File
@@ -0,0 +1,251 @@
package versionhistory
import (
"context"
"fmt"
"strings"
"testing"
"time"
"knowledge-governance/internal/governance"
)
type fakeSource struct {
commits []RawCommit
total int
pageSize int
tags []RawTag
changelog map[string]string
head string
headErr error
commitsErr error
listCalls int
}
func (f *fakeSource) PageSize() int { return f.pageSize }
func (f *fakeSource) CommitURLBase() string {
return "http://gitea.test/Weknora/knowledge-governance/commit/"
}
func (f *fakeSource) ListCommits(_ context.Context, page, limit int) ([]RawCommit, int, error) {
f.listCalls++
if f.commitsErr != nil {
return nil, 0, f.commitsErr
}
start := (page - 1) * limit
if start >= len(f.commits) {
return nil, f.total, nil
}
end := start + limit
if end > len(f.commits) {
end = len(f.commits)
}
return f.commits[start:end], f.total, nil
}
func (f *fakeSource) ListTags(context.Context) ([]RawTag, error) { return f.tags, nil }
func (f *fakeSource) ReadFile(_ context.Context, sha, _ string) (string, error) {
if text, ok := f.changelog[sha]; ok {
return text, nil
}
return "", fmt.Errorf("no changelog for %s", sha)
}
func (f *fakeSource) BranchHeadSHA(context.Context, string) (string, error) {
if f.headErr != nil {
return "", f.headErr
}
return f.head, nil
}
type savedSnapshot struct {
snapshotID int64
records []governance.VersionHistoryCommit
expected int
complete bool
}
type fakeStore struct {
beginCalls int
beginErr error
saved []savedSnapshot
finished []SyncOutcome
failed []string
activeSnapshot int64
lastSuccess time.Time
saveErr error
}
func (f *fakeStore) BeginVersionHistorySync(_ context.Context, _, _, _ string, _ time.Time) (int64, error) {
f.beginCalls++
if f.beginErr != nil {
return 0, f.beginErr
}
return int64(f.beginCalls), nil
}
func (f *fakeStore) SaveVersionHistorySnapshot(_ context.Context, snapshotID int64, _ string,
records []governance.VersionHistoryCommit, expected int, complete bool, _ time.Time) error {
if f.saveErr != nil {
return f.saveErr
}
f.saved = append(f.saved, savedSnapshot{snapshotID: snapshotID, records: records,
expected: expected, complete: complete})
if complete {
f.activeSnapshot = snapshotID
}
return nil
}
func (f *fakeStore) FinishVersionHistorySync(_ context.Context, _ int64, outcome SyncOutcome, now time.Time) error {
f.finished = append(f.finished, outcome)
f.lastSuccess = now
return nil
}
func (f *fakeStore) FailVersionHistorySync(_ context.Context, _ int64, code string, _ time.Time) error {
f.failed = append(f.failed, code)
return nil
}
func linearCommits(count int) []RawCommit {
commits := make([]RawCommit, 0, count)
for index := 0; index < count; index++ {
sha := fmt.Sprintf("%040x", index+1)
parents := []string{}
if index > 0 {
parents = append(parents, fmt.Sprintf("%040x", index))
}
commits = append(commits, RawCommit{SHA: sha, Parents: parents,
CommittedAt: time.Date(2026, 10, 13, 9, 0, 0, 0, time.UTC).Add(time.Duration(index) * time.Minute),
AuthorDisplay: "姚章浩", Subject: fmt.Sprintf("feat: 第 %d 条", index)})
}
return commits
}
func newTestSyncer(t *testing.T, source Source, store SyncStore) *Syncer {
t.Helper()
syncer, err := NewSyncer(SyncerConfig{Source: source, Store: store, RepoKey: "Weknora/knowledge-governance",
Now: func() time.Time { return time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC) }})
if err != nil {
t.Fatalf("装配同步器:%v", err)
}
return syncer
}
func TestSyncPullsEveryPageAndReconcilesTotal(t *testing.T) {
commits := linearCommits(111)
source := &fakeSource{commits: commits, total: 111, pageSize: 50, head: commits[110].SHA,
changelog: map[string]string{}}
store := &fakeStore{}
run, err := newTestSyncer(t, source, store).Sync(context.Background(), "job-1", "admin")
if err != nil {
t.Fatalf("同步失败:%v", err)
}
if source.listCalls != 3 {
t.Fatalf("应抓 3 页,实际 %d 次", source.listCalls)
}
if len(store.saved) != 1 || len(store.saved[0].records) != 111 {
t.Fatalf("落库记录数 = %d", len(store.saved[0].records))
}
seen := map[string]bool{}
for _, record := range store.saved[0].records {
if seen[record.CommitSHA] {
t.Fatalf("落库出现重复提交 %s", record.CommitSHA)
}
seen[record.CommitSHA] = true
}
if !run.Complete || run.FetchedCount != 111 || run.SourceHeadSHA != commits[110].SHA {
t.Fatalf("同步结果不完整:%+v", run)
}
if store.activeSnapshot != 1 || len(store.finished) != 1 || len(store.failed) != 0 {
t.Fatalf("快照切换/完成记录不对:%+v", store)
}
}
func TestSyncFailsLoudlyWhenCountDoesNotMatch(t *testing.T) {
commits := linearCommits(100) // 远端宣称 121,实际只给 100
source := &fakeSource{commits: commits, total: 121, pageSize: 50, head: commits[99].SHA}
store := &fakeStore{activeSnapshot: 7}
_, err := newTestSyncer(t, source, store).Sync(context.Background(), "job-2", "admin")
if err == nil {
t.Fatal("数量不一致必须报错,不能宣称完整")
}
if len(store.failed) != 1 || store.failed[0] != ErrorCodeCountMismatch {
t.Fatalf("错误码 = %v,期望 %s", store.failed, ErrorCodeCountMismatch)
}
if len(store.saved) != 0 || store.activeSnapshot != 7 {
t.Fatalf("失败时不得写快照、不得切换对外快照:%+v", store)
}
}
func TestSyncKeepsPreviousSnapshotWhenGiteaIsUnreachable(t *testing.T) {
source := &fakeSource{pageSize: 50, commitsErr: newGiteaError(KindUnreachable, 0, "不可达", nil)}
store := &fakeStore{activeSnapshot: 3, lastSuccess: time.Date(2026, 10, 12, 0, 0, 0, 0, time.UTC)}
_, err := newTestSyncer(t, source, store).Sync(context.Background(), "job-3", "admin")
if err == nil {
t.Fatal("不可达必须报错")
}
if len(store.failed) != 1 || store.failed[0] != ErrorCodeUnreachable {
t.Fatalf("错误码 = %v", store.failed)
}
if store.activeSnapshot != 3 || !store.lastSuccess.Equal(time.Date(2026, 10, 12, 0, 0, 0, 0, time.UTC)) {
t.Fatalf("失败不得动既有快照与最后成功时间:%+v", store)
}
}
func TestSyncIsIdempotentAndPicksUpTagsAddedAfterwards(t *testing.T) {
commits := linearCommits(3)
changelog := map[string]string{}
for _, commit := range commits {
changelog[commit.SHA] = "# Changelog\n\n## [Unreleased]\n\n### Added\n\n- 新\n\n## [0.1.0] - 2026-10-01\n\n### Added\n\n- 首次发布\n"
}
source := &fakeSource{commits: commits, total: 3, pageSize: 50, head: commits[2].SHA, changelog: changelog}
store := &fakeStore{}
syncer := newTestSyncer(t, source, store)
if _, err := syncer.Sync(context.Background(), "job-4", "admin"); err != nil {
t.Fatalf("首次同步:%v", err)
}
first := store.saved[0].records
if len(first) != 3 {
t.Fatalf("记录数 = %d", len(first))
}
// 还没有 tag ⇒ 全都是历史提交(不给号)
for _, record := range first {
if record.VersionLabel != "" || record.RecordKind != governance.RecordKindHistorical {
t.Fatalf("无 tag 时不应给号:%+v", record)
}
}
// 旧提交后补一个注释 tag ⇒ 再同步不新增记录,只把 A 升级成正式版本,B/C 拿到开发号
source.tags = []RawTag{{Name: "v0.1.0", TagSHA: strings.Repeat("a", 40), TargetSHA: commits[0].SHA, Annotated: true}}
if _, err := syncer.Sync(context.Background(), "job-5", "admin"); err != nil {
t.Fatalf("二次同步:%v", err)
}
second := store.saved[1].records
if len(second) != len(first) {
t.Fatalf("后补 tag 不得新增记录:%d → %d", len(first), len(second))
}
bySHA := map[string]governance.VersionHistoryCommit{}
for _, record := range second {
bySHA[record.CommitSHA] = record
}
if got := bySHA[commits[0].SHA]; got.RecordKind != governance.RecordKindRelease || got.VersionLabel != "0.1.0" {
t.Fatalf("tag 指向的提交应升级为正式版本:%+v", got)
}
if got := bySHA[commits[1].SHA]; got.VersionLabel != "0.1.1" || got.RecordKind != governance.RecordKindDevelopment {
t.Fatalf("tag 之后的提交应拿到开发号:%+v", got)
}
if got := bySHA[commits[2].SHA]; got.VersionLabel != "0.1.2" {
t.Fatalf("第二个提交应是 0.1.2:%+v", got)
}
}
func TestSyncerRequiresDependencies(t *testing.T) {
if _, err := NewSyncer(SyncerConfig{RepoKey: "a/b"}); err == nil {
t.Fatal("缺数据源/存储必须拒绝装配")
}
if _, err := NewSyncer(SyncerConfig{Source: &fakeSource{}, Store: &fakeStore{}}); err == nil {
t.Fatal("缺仓库标识必须拒绝装配")
}
}
+5
View File
@@ -53,6 +53,11 @@
"package": "knowledge-governance/internal/version",
"test": "TestReleaseBuildRequiresEvidence",
"reason": "构建身份未注入时按设计 SKIP(本地跑全量测试不该被迫先构建);**它的强制点在 contracts 项**:那里带 -ldflags 跑它并断言真的 PASS、且出现 SKIP 就判红"
},
{
"package": "knowledge-governance/internal/versionhistory",
"test": "TestLiveSyncAgainstRealRepository",
"reason": "真实仓库的只读同步用例需要内部可达的 Gitea(**匿名**,不读凭据);无环境即跳过(补验路径:设 VERSION_HISTORY_LIVE_BASE_URL 后单跑这条)"
}
]
}
+2057 -632
View File
File diff suppressed because it is too large Load Diff
+209
View File
@@ -0,0 +1,209 @@
// @ts-expect-error The test runner provides Node; the browser bundle intentionally omits @types/node.
import { readFileSync } from 'node:fs'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import { VersionHistoryView } from './VersionHistory'
import type { VersionHistoryItem, VersionHistoryPage } from './versionHistory'
const releaseItem: VersionHistoryItem = {
commit_sha: '6de5d5236380ad619fe35e4bd20f49b2d967903e',
short_sha: '6de5d5236380',
parents: ['97f4b296613e23889997dd14004cd23f6c3a2c3f'],
committed_at: '2026-10-13T16:45:56+08:00',
author_display: '姚章浩',
subject: 'feat(version): 开发号随提交自动递增 + 首个正式版本 0.1.0',
body: '正文第一行\n正文第二行',
record_kind: 'release',
version_label: '0.1.0',
version_state: 'verified',
release_tags: [{ name: 'v0.1.0', url: 'https://gitea.example/Weknora/knowledge-governance/src/tag/v0.1.0' }],
release_notes: '### Added\n\n- 首个正式版本',
commit_url: 'https://gitea.example/Weknora/knowledge-governance/commit/6de5d5236380ad619fe35e4bd20f49b2d967903e',
version_reason: 'annotated_tag_and_changelog_top_match',
is_current_running: true,
}
const developmentItem: VersionHistoryItem = {
...releaseItem,
commit_sha: '950c1b429bc814d9fa7c2c1449f999a05537af21',
short_sha: '950c1b429bc8',
subject: 'feat(release): 抬档门禁',
body: '',
record_kind: 'development',
version_label: '0.1.1',
version_state: 'verified',
release_tags: [],
release_notes: '',
is_current_running: false,
}
const historicalItem: VersionHistoryItem = {
...releaseItem,
commit_sha: 'e1c5f68c0d8a4f2b9c3d5e7a1b2c3d4e5f607182',
short_sha: 'e1c5f68c0d8a',
subject: 'chore(deploy): 部署与备份脚本',
body: '',
record_kind: 'historical',
version_label: '',
version_state: 'unknown',
release_tags: [],
release_notes: '',
version_reason: 'no_baseline',
is_current_running: false,
commit_url: 'https://gitea.example/Weknora/knowledge-governance/commit/e1c5f68c0d8a4f2b9c3d5e7a1b2c3d4e5f607182',
}
const page: VersionHistoryPage = {
items: [releaseItem, developmentItem, historicalItem],
next_cursor: 'cursor-2',
has_more: true,
sync: {
available: true,
status: 'completed',
complete: true,
last_success_at: '2026-10-13T20:00:00+08:00',
fetched_count: 111,
expected_count: 111,
error_code: '',
source_head_sha: 'ad9c5d20d467…',
active_snapshot_id: 12,
repo: 'Weknora/knowledge-governance',
},
current_running: {
state: 'matched',
commit_sha: '6de5d5236380ad619fe35e4bd20f49b2d967903e',
display_version: '0.1.0',
matched_commit_sha: '6de5d5236380ad619fe35e4bd20f49b2d967903e',
},
}
const render = (over: Partial<Parameters<typeof VersionHistoryView>[0]> = {}) =>
renderToStaticMarkup(
<VersionHistoryView
page={page}
build={null}
filter="all"
loading={false}
loadingMore={false}
error={null}
notice={null}
expanded={null}
canSync={false}
onFilter={vi.fn()}
onLoadMore={vi.fn()}
onSync={vi.fn()}
onExpand={vi.fn()}
{...over}
/>
)
describe('VersionHistoryView', () => {
it('lists commits newest-first with kind badges, short sha, times and links', () => {
const html = render()
expect(html).toContain('版本记录')
expect(html).toContain('正式版本')
expect(html).toContain('开发版本')
expect(html).toContain('历史提交')
expect(html).toContain('0.1.0')
expect(html).toContain('0.1.1')
expect(html).toContain('950c1b429bc8')
expect(html).toContain('查看提交')
expect(html).toContain('查看 tag v0.1.0')
expect(html.indexOf('0.1.0')).toBeLessThan(html.indexOf('0.1.1'))
})
it('marks only the exact-match commit as current running', () => {
const html = render()
const flags = html.match(/当前运行/g) ?? []
// 顶部一句「当前运行:…」+ 恰好一个条目徽标
expect(flags.length).toBe(2)
expect(html).toContain('已匹配到下面的提交')
})
it('never labels a commit when the running identity is unknown', () => {
const html = render({
page: {
...page,
current_running: { state: 'unknown', commit_sha: '', display_version: 'dev', matched_commit_sha: '' },
},
})
expect(html).toContain('当前运行版本未知')
expect(html).not.toContain('当前运行</span>')
expect(html).not.toContain('已匹配到下面的提交')
})
it('does not invent a version number for unverified history commits', () => {
const html = render()
const start = html.indexOf('历史提交 · e1c5f68c0d8a')
expect(start).toBeGreaterThan(-1)
expect(html).toContain('版本号未能核验:找不到可核验的发布基线')
expect(html).not.toMatch(/历史提交 · e1c5f68c0d8a[\s\S]{0,200}?\d+\.\d+\.\d+[\s\S]{0,40}?版本号未能核验/)
})
it('keeps two commits that resolve to the same number as separate entries', () => {
const sameNumber: VersionHistoryItem = {
...developmentItem,
commit_sha: 'c'.repeat(40),
short_sha: 'cccccccccccc',
subject: '另一条同号提交',
}
const html = render({ page: { ...page, items: [developmentItem, sameNumber] } })
expect(html).toContain('950c1b429bc8')
expect(html).toContain('cccccccccccc')
expect((html.match(/0\.1\.1/g) ?? []).length).toBe(2)
})
it('shows an honest empty state and never offers tag/publish/deploy actions', () => {
const html = render({ page: { ...page, items: [], has_more: false } })
expect(html).toContain('暂无记录')
expect(html).not.toContain('打 tag')
expect(html).not.toContain('发布</button>')
expect(html).not.toContain('部署</button>')
// 非管理员看不到同步入口
expect(html).not.toContain('同步记录</button>')
})
it('exposes the admin sync entry only for admins and reflects sync failures', () => {
const html = render({ canSync: true })
expect(html).toContain('同步记录')
const failed = render({
canSync: true,
page: {
...page,
sync: { ...page.sync, status: 'failed', complete: false, error_code: 'gitea_unreachable' },
},
})
expect(failed).toContain('上次同步失败(gitea_unreachable)')
expect(failed).toContain('仍展示上次成功的数据')
expect(failed).toContain('同步失败')
})
it('degrades to "not wired" instead of failing when the source is unconfigured', () => {
const html = render({
page: { ...page, items: [], sync: { ...page.sync, available: false, status: '' } },
})
expect(html).toContain('未接线')
expect(html).not.toContain('同步记录</button>')
})
it('keeps mobile single-column rules so nothing overflows horizontally', () => {
const css = readFileSync(new URL('./versionHistory.css', import.meta.url), 'utf8')
expect(css).toMatch(/@media \(max-width: 760px\)[\s\S]*?\.version-history[\s\S]*?overflow-wrap:\s*anywhere/)
expect(css).toMatch(
/@media \(max-width: 760px\)[\s\S]*?\.version-history-entry-head\s*\{\s*flex-direction:\s*column/
)
})
it('renders commit bodies as text, never as markup', () => {
const html = render({
expanded: developmentItem.commit_sha,
page: {
...page,
items: [{ ...developmentItem, body: '<img src=x onerror=alert(1)>', release_notes: '<script>x</script>' }],
},
})
expect(html).not.toContain('<img src=x')
expect(html).not.toContain('<script>x</script>')
expect(html).toContain('&lt;img src=x')
})
})
+285
View File
@@ -0,0 +1,285 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
fetchVersionHistory,
fetchBuildInfo,
reasonLabel,
syncVersionHistory,
type BuildInfo,
type VersionHistoryItem,
type VersionHistoryPage,
} from './versionHistory'
import { useOptionalAuth } from './auth'
import './versionHistory.css'
// 「版本记录」页:追踪**所有已进入远端 main 的提交**,按提交时间倒序。
// 只读:不提供打 tag / 发布 / 部署按钮;版本号只按证据展示,证据不足就说"历史提交"。
const FILTERS: ReadonlyArray<{ id: string; label: string }> = [
{ id: 'all', label: '全部' },
{ id: 'release', label: '正式' },
{ id: 'development', label: '开发' },
{ id: 'historical', label: '历史' },
]
const stamp = (value: string) => (value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—')
const kindLabel: Record<string, string> = { release: '正式版本', development: '开发版本', historical: '历史提交' }
const runningText = (running: VersionHistoryPage['current_running']) => {
if (running.state === 'matched')
return `当前运行:${running.display_version}(${running.commit_sha.slice(0, 12)},已匹配到下面的提交)`
if (running.state === 'development') {
return `当前运行:开发构建 ${running.display_version}(${running.commit_sha.slice(0, 12)})—— 这份产物不在已同步的提交里`
}
return '当前运行版本未知(产物没有注入可核验的提交标识)'
}
const syncText = (sync: VersionHistoryPage['sync']) => {
if (!sync.available) return '未接线:还没有配置只读的仓库来源'
if (!sync.status) return '尚未同步过'
if (sync.status === 'running' || sync.status === 'pending') return `同步中(${sync.status})`
if (sync.status === 'failed') {
return `上次同步失败(${sync.error_code || '未知原因'})· 仍展示上次成功的数据(${stamp(sync.last_success_at)})`
}
if (!sync.complete) {
return `上次同步:部分历史(${sync.fetched_count}/${sync.expected_count ?? '?'})· ${stamp(sync.last_success_at)}`
}
return `最后同步:${stamp(sync.last_success_at)}(${sync.fetched_count} 条提交)`
}
interface ViewProps {
page: VersionHistoryPage | null
build: BuildInfo | null
filter: string
loading: boolean
loadingMore: boolean
error: string | null
notice: string | null
expanded: string | null
canSync: boolean
onFilter: (id: string) => void
onLoadMore: () => void
onSync: () => void
onExpand: (sha: string | null) => void
}
export function VersionHistoryView(props: ViewProps) {
const { page, build } = props
const items = page?.items ?? []
return (
<section className="version-history" aria-label="版本记录">
<header className="version-history-header">
<div>
<h2>版本记录</h2>
<p>
{page
? runningText(page.current_running)
: build
? `当前运行:${build.identity_available ? build.display_version : '未知'}`
: '当前运行版本未知'}
</p>
<p className="version-history-sync-line">
{page ? syncText(page.sync) : '正在读取同步状态…'}
{page?.sync.status === 'running' || page?.sync.status === 'pending' ? (
<span className="version-history-badge pending">同步中</span>
) : null}
{page && page.sync.status === 'failed' ? (
<span className="version-history-badge warn">同步失败</span>
) : null}
</p>
</div>
{props.canSync ? (
<button type="button" onClick={props.onSync} disabled={props.loading || !page?.sync.available}>
{page?.sync.status === 'running' || page?.sync.status === 'pending' ? '同步进行中…' : '同步记录'}
</button>
) : null}
</header>
{props.error ? (
<div className="error-banner" role="alert">
{props.error}
</div>
) : null}
{props.notice ? <div className="version-history-notice">{props.notice}</div> : null}
<nav className="version-history-filters" aria-label="按记录类型筛选">
{FILTERS.map((entry) => (
<button
type="button"
key={entry.id}
className={props.filter === entry.id ? 'active' : ''}
aria-pressed={props.filter === entry.id}
onClick={() => props.onFilter(entry.id)}
>
{entry.label}
</button>
))}
</nav>
{items.length === 0 && !props.loading ? (
<div className="empty-state">
{page?.sync.available
? '暂无记录(先同步一次,或该筛选下没有条目)'
: '版本记录未接线:等待配置只读的仓库来源'}
</div>
) : null}
<ol className="version-history-timeline">
{items.map((item) => (
<TimelineEntry
key={item.commit_sha}
item={item}
// 「当前运行」以页面级判定为准(同一份事实),条目标志只做兜底
currentMatchedSHA={page?.current_running.state === 'matched' ? page.current_running.matched_commit_sha : ''}
expanded={props.expanded === item.commit_sha}
onExpand={() => props.onExpand(props.expanded === item.commit_sha ? null : item.commit_sha)}
/>
))}
</ol>
{props.loading ? <div className="empty-state">正在读取版本记录…</div> : null}
{page?.has_more ? (
<button type="button" className="version-history-more" onClick={props.onLoadMore} disabled={props.loadingMore}>
{props.loadingMore ? '正在加载…' : '加载更多'}
</button>
) : null}
</section>
)
}
function TimelineEntry({
item,
currentMatchedSHA,
expanded,
onExpand,
}: {
item: VersionHistoryItem
currentMatchedSHA: string
expanded: boolean
onExpand: () => void
}) {
const isRelease = item.record_kind === 'release' && item.version_state === 'verified'
const heading = isRelease ? item.version_label : item.version_label || `历史提交 · ${item.short_sha}`
const note = isRelease ? item.release_notes : ''
const hasDetail = Boolean(item.body) || Boolean(note)
return (
<li className={`version-history-entry ${item.record_kind}`}>
<div className="version-history-entry-head">
<strong>{heading}</strong>
<span>{stamp(item.committed_at)}</span>
</div>
<div className="version-history-entry-meta">
<span className={`version-history-badge ${item.record_kind}`}>{kindLabel[item.record_kind] ?? '历史提交'}</span>
<span className="version-history-sha">{item.short_sha}</span>
{item.commit_sha && item.commit_sha === currentMatchedSHA ? (
<span className="version-history-badge current">当前运行</span>
) : null}
<span className="version-history-author">{item.author_display}</span>
</div>
<p className="version-history-subject">{item.subject}</p>
{!item.version_label && item.record_kind === 'historical' ? (
<p className="version-history-reason">版本号未能核验:{reasonLabel(item.version_reason)}</p>
) : null}
{hasDetail ? (
<>
<button type="button" className="version-history-toggle" onClick={onExpand} aria-expanded={expanded}>
{expanded ? '收起详情' : '展开详情'}
</button>
{expanded ? (
<div className="version-history-detail">
{item.record_kind === 'release' && note ? <pre>{note}</pre> : null}
{item.body ? <pre>{item.body}</pre> : null}
</div>
) : null}
</>
) : null}
<div className="version-history-links">
{item.commit_url ? (
<a href={item.commit_url} target="_blank" rel="noreferrer noopener">
查看提交
</a>
) : null}
{item.release_tags.map((tag) => (
<a key={tag.name} href={tag.url} target="_blank" rel="noreferrer noopener">
查看 tag {tag.name}
</a>
))}
</div>
</li>
)
}
export function VersionHistory() {
const auth = useOptionalAuth()
const [page, setPage] = useState<VersionHistoryPage | null>(null)
const [build, setBuild] = useState<BuildInfo | null>(null)
const [filter, setFilter] = useState('all')
const [loading, setLoading] = useState(true)
const [loadingMore, setLoadingMore] = useState(false)
const [error, setError] = useState<string | null>(null)
const [notice, setNotice] = useState<string | null>(null)
const [expanded, setExpanded] = useState<string | null>(null)
const cursor = useRef('')
const load = useCallback(async (kind: string, append: boolean) => {
append ? setLoadingMore(true) : setLoading(true)
try {
const next = await fetchVersionHistory({ kind, cursor: append ? cursor.current : '' })
cursor.current = next.next_cursor
setPage((current) => (append && current ? { ...next, items: [...current.items, ...next.items] } : next))
setError(null)
} catch (cause) {
setError(cause instanceof Error ? cause.message : '加载版本记录失败')
} finally {
append ? setLoadingMore(false) : setLoading(false)
}
}, [])
useEffect(() => {
void load(filter, false)
}, [filter, load])
useEffect(() => {
let cancelled = false
void fetchBuildInfo()
.then((value) => {
if (!cancelled) setBuild(value)
})
.catch(() => undefined)
return () => {
cancelled = true
}
}, [])
// 只有管理员能看到同步入口;其他人只能看(服务端也会再判一次角色)
const canSync = auth?.identity?.role === 'admin'
const onSync = useCallback(async () => {
setNotice(null)
try {
const accepted = await syncVersionHistory()
setNotice(accepted.reused ? '已有同步任务在跑,本次复用它。' : '已提交同步任务,稍后刷新即可看到新记录。')
await load(filter, false)
} catch (cause) {
setError(cause instanceof Error ? cause.message : '触发同步失败')
}
}, [filter, load])
const viewProps = useMemo<ViewProps>(
() => ({
page,
build,
filter,
loading,
loadingMore,
error,
notice,
expanded,
canSync,
onFilter: setFilter,
onLoadMore: () => void load(filter, true),
onSync: () => void onSync(),
onExpand: setExpanded,
}),
[page, build, filter, loading, loadingMore, error, notice, expanded, canSync, load, onSync]
)
return <VersionHistoryView {...viewProps} />
}
+171
View File
@@ -0,0 +1,171 @@
/* 「版本记录」页:只读时间线。窄屏(≤760px,与工作台既有断点一致)单列、无横向溢出。 */
.version-history {
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0;
}
.version-history-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.version-history-header h2 {
margin: 0 0 4px;
font-size: 18px;
}
.version-history-header p {
margin: 0;
color: var(--muted, #666);
font-size: 13px;
}
.version-history-sync-line {
display: flex;
align-items: center;
gap: 8px;
margin-top: 4px !important;
}
.version-history-notice {
padding: 8px 10px;
border-radius: 6px;
background: rgba(64, 158, 255, 0.12);
font-size: 13px;
}
.version-history-filters {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.version-history-filters button.active {
font-weight: 600;
border-color: currentColor;
}
.version-history-timeline {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
}
.version-history-entry {
border-left: 3px solid rgba(127, 127, 127, 0.35);
padding: 10px 12px;
background: rgba(127, 127, 127, 0.06);
border-radius: 6px;
min-width: 0;
}
.version-history-entry.release {
border-left-color: #2f9e44;
}
.version-history-entry.development {
border-left-color: #1c7ed6;
}
.version-history-entry-head {
display: flex;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
}
.version-history-entry-head strong {
font-size: 15px;
}
.version-history-entry-meta {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-top: 4px;
font-size: 12px;
color: var(--muted, #666);
}
.version-history-badge {
padding: 1px 6px;
border-radius: 999px;
border: 1px solid currentColor;
font-size: 12px;
}
.version-history-badge.release {
color: #2f9e44;
}
.version-history-badge.development {
color: #1c7ed6;
}
.version-history-badge.historical {
color: #868e96;
}
.version-history-badge.current {
color: #e8590c;
}
.version-history-badge.pending {
color: #1c7ed6;
}
.version-history-badge.warn {
color: #c92a2a;
}
.version-history-sha {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
overflow-wrap: anywhere;
}
.version-history-subject {
margin: 6px 0 0;
overflow-wrap: anywhere;
}
.version-history-reason {
margin: 4px 0 0;
font-size: 12px;
color: #c92a2a;
overflow-wrap: anywhere;
}
.version-history-toggle {
margin-top: 6px;
}
.version-history-detail pre {
margin: 6px 0 0;
padding: 8px;
max-height: 320px;
overflow: auto;
background: rgba(127, 127, 127, 0.1);
border-radius: 6px;
white-space: pre-wrap;
overflow-wrap: anywhere;
font-size: 12px;
}
.version-history-links {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-top: 6px;
font-size: 13px;
}
@media (max-width: 760px) {
.version-history-header {
flex-direction: column;
align-items: stretch;
}
.version-history-entry {
padding: 8px 10px;
}
.version-history-entry-head {
flex-direction: column;
gap: 2px;
}
/* 窄屏单列:正文/标题/hash 一律换行,不出现横向滚动 */
.version-history,
.version-history-timeline,
.version-history-entry,
.version-history-subject,
.version-history-sha,
.version-history-reason {
max-width: 100%;
overflow-wrap: anywhere;
word-break: break-word;
}
.version-history-detail pre {
max-height: none;
}
}
+140
View File
@@ -0,0 +1,140 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { fetchBuildInfo, fetchVersionHistory, reasonLabel, syncVersionHistory } from './versionHistory'
const jsonResponse = (body: unknown, status = 200) =>
Promise.resolve({ ok: status >= 200 && status < 300, status, json: () => Promise.resolve(body) } as Response)
afterEach(() => vi.unstubAllGlobals())
const pageBody = {
items: [
{
commit_sha: 'a'.repeat(40),
short_sha: 'aaaaaaaaaaaa',
parents: [],
committed_at: '2026-10-13T10:00:00Z',
author_display: '姚章浩',
subject: 'feat: 甲',
body: '正文',
record_kind: 'development',
version_label: '0.1.2',
version_state: 'verified',
release_tags: [],
release_notes: '',
commit_url: 'https://gitea.example/commit/aaa',
version_reason: 'counted_commits_since_base_tag',
is_current_running: false,
},
],
next_cursor: 'next-cursor',
has_more: true,
sync: {
available: true,
status: 'completed',
complete: true,
last_success_at: '2026-10-13T12:00:00Z',
fetched_count: 111,
expected_count: 111,
error_code: '',
source_head_sha: 'a'.repeat(40),
active_snapshot_id: 3,
repo: 'Weknora/knowledge-governance',
},
current_running: {
state: 'development',
commit_sha: 'b'.repeat(40),
display_version: '0.1.2',
matched_commit_sha: '',
},
}
describe('versionHistory client', () => {
it('loads a page, keeps the cursor and reads sync/current-running facts', async () => {
const fetchMock = vi.fn((_input: RequestInfo | URL, _init?: RequestInit) => jsonResponse(pageBody))
vi.stubGlobal('fetch', fetchMock)
const page = await fetchVersionHistory({ kind: 'all', cursor: '' })
expect(fetchMock).toHaveBeenCalledOnce()
const url = String(fetchMock.mock.calls[0][0])
expect(url).toContain('/api/v1/version-history?')
expect(url).not.toContain('kind=all')
expect(url).toContain('limit=30')
expect(page.items[0].version_label).toBe('0.1.2')
expect(page.next_cursor).toBe('next-cursor')
expect(page.has_more).toBe(true)
expect(page.sync.expected_count).toBe(111)
expect(page.current_running.state).toBe('development')
})
it('passes the kind filter and cursor through', async () => {
const fetchMock = vi.fn((_input: RequestInfo | URL, _init?: RequestInit) =>
jsonResponse({ ...pageBody, items: [], has_more: false })
)
vi.stubGlobal('fetch', fetchMock)
await fetchVersionHistory({ kind: 'historical', cursor: 'c-1', limit: 50 })
const url = String(fetchMock.mock.calls[0][0])
expect(url).toContain('kind=historical')
expect(url).toContain('cursor=c-1')
expect(url).toContain('limit=50')
})
it('explains an expired cursor instead of showing a raw error', async () => {
vi.stubGlobal(
'fetch',
vi.fn(() => jsonResponse({ code: 'cursor_expired' }, 409))
)
await expect(fetchVersionHistory({ kind: 'all', cursor: 'stale' })).rejects.toThrow('已被清理')
})
it('rejects malformed payloads instead of rendering half-truths', async () => {
vi.stubGlobal(
'fetch',
vi.fn(() => jsonResponse({ items: 'nope' }))
)
await expect(fetchVersionHistory({ kind: 'all', cursor: '' })).rejects.toThrow('响应无效')
})
it('posts the sync request and reports reuse', async () => {
const fetchMock = vi.fn((_input: RequestInfo | URL, _init?: RequestInit) =>
jsonResponse({ job_id: 'job-1', status: 'running', reused: true })
)
vi.stubGlobal('fetch', fetchMock)
const accepted = await syncVersionHistory()
expect(String(fetchMock.mock.calls[0][0])).toBe('/api/v1/version-history/sync')
expect(fetchMock.mock.calls[0][1]).toMatchObject({ method: 'POST' })
expect(accepted.reused).toBe(true)
})
it('says admin-only when the sync is refused', async () => {
vi.stubGlobal(
'fetch',
vi.fn(() => jsonResponse({ code: 'forbidden_role' }, 403))
)
await expect(syncVersionHistory()).rejects.toThrow('只有管理员')
})
it('never fabricates a build identity when injection is missing', async () => {
vi.stubGlobal(
'fetch',
vi.fn(() =>
jsonResponse({
release_version: '',
build_kind: 'development',
commit_sha: null,
display_version: 'dev',
built_at: '',
identity_available: false,
})
)
)
const info = await fetchBuildInfo()
expect(info.commit_sha).toBeNull()
expect(info.identity_available).toBe(false)
})
it('maps every evidence reason to a human explanation', () => {
expect(reasonLabel('lightweight_tag')).toContain('轻量 tag')
expect(reasonLabel('no_baseline')).toContain('发布基线')
expect(reasonLabel('shallow_or_incomplete_graph')).toContain('提交图不完整')
expect(reasonLabel('something_new')).toBe('版本号未能核验')
})
})
+194
View File
@@ -0,0 +1,194 @@
// 「版本记录」页的客户端:只读查询 + 管理员手动同步。
// 页面读的是本库缓存(Gitea 不可达也能看),同步动作只允许管理员触发。
export type VersionRecordKind = 'release' | 'development' | 'historical'
export type VersionState = 'verified' | 'unknown'
export type CurrentRunningState = 'matched' | 'development' | 'unknown'
export interface VersionHistoryTag {
name: string
url: string
}
export interface VersionHistoryItem {
commit_sha: string
short_sha: string
parents: string[]
committed_at: string
author_display: string
subject: string
body: string
record_kind: VersionRecordKind
version_label: string
version_state: VersionState
release_tags: VersionHistoryTag[]
release_notes: string
commit_url: string
version_reason: string
is_current_running: boolean
}
export interface VersionHistorySyncState {
available: boolean
status: string
complete: boolean
last_success_at: string
fetched_count: number
expected_count: number | null
error_code: string
source_head_sha: string
active_snapshot_id: number
repo: string
}
export interface VersionHistoryCurrentRunning {
state: CurrentRunningState
commit_sha: string
display_version: string
matched_commit_sha: string
}
export interface VersionHistoryPage {
items: VersionHistoryItem[]
next_cursor: string
has_more: boolean
sync: VersionHistorySyncState
current_running: VersionHistoryCurrentRunning
}
export interface BuildInfo {
release_version: string
build_kind: string
commit_sha: string | null
display_version: string
built_at: string
identity_available: boolean
}
export interface SyncAccepted {
job_id: string
status: string
reused: boolean
}
const record = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null
const text = (value: unknown): string => (typeof value === 'string' ? value : '')
const bool = (value: unknown): boolean => value === true
const count = (value: unknown): number => (typeof value === 'number' && Number.isFinite(value) ? value : 0)
// 「版本号未能核验」的原因码 → 页面文案(不补造版本号,只解释为什么没有号)。
export const reasonLabels: Record<string, string> = {
annotated_tag_and_changelog_top_match: '注释 tag 与提交内版本记录一致',
counted_commits_since_base_tag: '按发布基线数出来的开发号',
lightweight_tag: '该提交上的 tag 是轻量 tag,不作为正式版本证据',
no_changelog_top: '该提交内没有已发布版本的变更记录',
changelog_top_mismatch: 'tag 版本与该提交内的变更记录不一致',
no_release_tag_for_commit: '没有指向该提交的正式 tag',
no_baseline: '找不到可核验的发布基线(该版本没有对应的 tag)',
shallow_or_incomplete_graph: '提交图不完整(检出被截断),提交数算不准',
}
export const reasonLabel = (reason: string): string => reasonLabels[reason] ?? '版本号未能核验'
const parseItem = (value: unknown): VersionHistoryItem => {
if (!record(value)) throw new Error('版本记录条目无效')
return {
commit_sha: text(value.commit_sha),
short_sha: text(value.short_sha),
parents: Array.isArray(value.parents)
? value.parents.filter((item): item is string => typeof item === 'string')
: [],
committed_at: text(value.committed_at),
author_display: text(value.author_display),
subject: text(value.subject),
body: text(value.body),
record_kind: (text(value.record_kind) || 'historical') as VersionRecordKind,
version_label: text(value.version_label),
version_state: (text(value.version_state) || 'unknown') as VersionState,
release_tags: Array.isArray(value.release_tags)
? value.release_tags.filter(record).map((tag) => ({ name: text(tag.name), url: text(tag.url) }))
: [],
release_notes: text(value.release_notes),
commit_url: text(value.commit_url),
version_reason: text(value.version_reason),
is_current_running: bool(value.is_current_running),
}
}
export async function fetchVersionHistory(
params: { kind: string; cursor: string; limit?: number },
signal?: AbortSignal
): Promise<VersionHistoryPage> {
const query = new URLSearchParams()
if (params.kind && params.kind !== 'all') query.set('kind', params.kind)
if (params.cursor) query.set('cursor', params.cursor)
query.set('limit', String(params.limit ?? 30))
const response = await fetch(`/api/v1/version-history?${query.toString()}`, {
headers: { Accept: 'application/json' },
signal,
})
const value = (await response.json().catch(() => null)) as unknown
if (!response.ok) {
const code = record(value) ? text(value.code) : ''
if (response.status === 409 || code === 'cursor_expired') {
throw new Error('这次翻页依据的记录快照已被清理,请刷新页面重新加载。')
}
throw new Error(`加载版本记录失败(HTTP ${response.status})`)
}
if (!record(value) || !Array.isArray(value.items) || !record(value.sync) || !record(value.current_running)) {
throw new Error('版本记录响应无效')
}
const sync = value.sync
const running = value.current_running
const expected = typeof sync.expected_count === 'number' ? sync.expected_count : null
return {
items: value.items.map(parseItem),
next_cursor: text(value.next_cursor),
has_more: bool(value.has_more),
sync: {
available: bool(sync.available),
status: text(sync.status),
complete: bool(sync.complete),
last_success_at: text(sync.last_success_at),
fetched_count: count(sync.fetched_count),
expected_count: expected,
error_code: text(sync.error_code),
source_head_sha: text(sync.source_head_sha),
active_snapshot_id: count(sync.active_snapshot_id),
repo: text(sync.repo),
},
current_running: {
state: (text(running.state) || 'unknown') as CurrentRunningState,
commit_sha: text(running.commit_sha),
display_version: text(running.display_version),
matched_commit_sha: text(running.matched_commit_sha),
},
}
}
export async function syncVersionHistory(): Promise<SyncAccepted> {
const response = await fetch('/api/v1/version-history/sync', {
method: 'POST',
headers: { Accept: 'application/json' },
})
const value = (await response.json().catch(() => null)) as unknown
if (!response.ok) {
throw new Error(response.status === 403 ? '只有管理员能触发同步。' : `触发同步失败(HTTP ${response.status})`)
}
if (!record(value)) throw new Error('同步响应无效')
return { job_id: text(value.job_id), status: text(value.status), reused: bool(value.reused) }
}
export async function fetchBuildInfo(signal?: AbortSignal): Promise<BuildInfo> {
const response = await fetch('/api/v1/build-info', { headers: { Accept: 'application/json' }, signal })
if (!response.ok) throw new Error(`读取当前运行版本失败(HTTP ${response.status})`)
const value = (await response.json()) as unknown
if (!record(value)) throw new Error('构建身份响应无效')
return {
release_version: text(value.release_version),
build_kind: text(value.build_kind),
commit_sha: typeof value.commit_sha === 'string' ? value.commit_sha : null,
display_version: text(value.display_version),
built_at: text(value.built_at),
identity_available: bool(value.identity_available),
}
}
+4 -9
View File
@@ -1,16 +1,11 @@
export type ModuleId = 'dashboard' | 'sources' | 'knowledge' | 'tasks' | 'releases' | 'settings'
export type ModuleId = 'dashboard' | 'sources' | 'knowledge' | 'tasks' | 'releases' | 'settings' | 'versions'
export interface WorkspaceState {
openTabs: ModuleId[]
activeTab: ModuleId
}
const moduleIds = new Set<ModuleId>([
'dashboard',
'tasks',
'releases',
'settings',
])
const moduleIds = new Set<ModuleId>(['dashboard', 'tasks', 'releases', 'settings'])
export const initialWorkspace: WorkspaceState = {
openTabs: ['dashboard'],
@@ -45,10 +40,10 @@ export function restoreWorkspace(raw: string | null): WorkspaceState {
const saved = JSON.parse(raw) as { openTabs?: unknown; activeTab?: unknown }
if (!Array.isArray(saved.openTabs)) return initialWorkspace
const restoredTabs = saved.openTabs.filter(
(value): value is ModuleId => typeof value === 'string' && moduleIds.has(value as ModuleId),
(value): value is ModuleId => typeof value === 'string' && moduleIds.has(value as ModuleId)
)
const openTabs = ['dashboard', ...restoredTabs.filter((tab) => tab !== 'dashboard')].filter(
(tab, index, tabs) => tabs.indexOf(tab) === index,
(tab, index, tabs) => tabs.indexOf(tab) === index
) as ModuleId[]
const activeTab =
typeof saved.activeTab === 'string' &&