feat(version-history): 「版本记录」页 —— 追踪远端 main 全部提交(只读 + 管理员手动同步)
build / build (push) Successful in 57s
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:
@@ -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
|
||||
|
||||
@@ -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 的版本 == 它指到那个提交里的
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 给历史提交重新编号,不给证据不足的提交编版本号。
|
||||
- 不把「软件版本」与「知识发布批次」混在一起(发布批次在「发布管理」)。
|
||||
@@ -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
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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("版本比较不对")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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("绝对路径必须拒绝")
|
||||
}
|
||||
}
|
||||
@@ -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{}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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("缺仓库标识必须拒绝装配")
|
||||
}
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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('<img src=x')
|
||||
})
|
||||
})
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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('版本号未能核验')
|
||||
})
|
||||
})
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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' &&
|
||||
|
||||
Reference in New Issue
Block a user